Primitive Obsession

Primitive Obsession is a code smell.

Code smells indicate potential problems or a deviation from good programming practices. They suggest a need for investigation and possibly some refactoring of the code.

Where there's smoke, there's fire.

If you see smoke, there's likely a fire somewhere, and it definitely warrants investigation. The same principle applies to code smells.

In this article, we'll explore:

  • how to recognise Primitive Obsession
  • understand what Primitive Obsession is telling us
  • how to fix it

Primitives are the built-in types within a programming language, such as: bool, int, string, float, array, etc.

Obsession is an extreme or unhealthy fixation on something, leading to potential harm.

Kinda like Homer with donuts!

Homer Simpson as a playing cardDonut as a playing card

Therefore, you could describe Primitive Obsession not only as an excessive use of primitive types, but also as an unhealthy dependency that negatively impacts your codebase.

Primitive Obsession can infiltrate our code for various reasons. Sometimes it’s a deliberate choice after evaluating trade-offs, but often it can be accidental.

As our species evolved, our bodies developed ways to save energy. Our brains developed shortcuts they can take when they need to think. One type of shortcut is called Cognitive Bias - an influence on our decision making based on personal views and opinions.

A form of Cognitive Bias is the Law of the Instrument (also known as Law of the Hammer, Maslow’s Hammer or the Golden Hammer). It’s a Cognitive Bias which illustrates our tendencies to have an over-reliance on familiar tools.

You may have heard of it with the phrase:

If the only tool you have is a hammer, it is tempting to treat everything as if it were a nail.

This is our brain taking a shortcut. We prefer to reach for what is familiar and comfortable - even if it might not be the best tool for the job.

When we are coding, our Cognitive Bias can manifest as Primitive Obsession. It’s like always using a hammer — sticking to basic data types when a better solution would be to model a new, more-specific type.

#Recognise

So far, we've looked at Primitive Obsession as the habit of heavily relying on simple data types to express more complex ideas.

However, the true power of understanding a code smell is to recognise the symptoms.

Just like how Design Patterns give us reusable solutions to common problems, code smells are recognisable symptoms of common issues.

Let’s do exactly that: examine some code and identify the signs of Primitive Obsession.

Parameter/Argument Ordering

When our code heavily relies on basic data types, it's easy to accidentally mix up the order of arguments.

Imagine the following class:

class User
{
    public function __construct(string $id, string $email)
    {
    }
}

We can create an instance of this class like this:

$user = new User('abc123', '[email protected]');

However, since both arguments are strings, we could:

$user = new User('[email protected]', 'abc123');

Both arguments have the same type, allowing the order to be easily mixed up without any type violations.

The value '[email protected]' is a valid string.

The value 'abc123' is also a valid string.

If you find yourself mixing up the order of arguments, especially when the same data type represents different ideas, it might be a sign of Primitive Obsession.

Encapsulation

We may need to implement additional email address validation in our software and consider adding some checks.

However, primitive types are unable to encapsulate behaviour. Because there is no way to place behaviour inside a primitive type — it is forced to exist outside:

if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
    throw new InvalidArgumentException('Invalid Email Address');
}

$user = new User('abc123', $email);

The lack of encapsulation prevents us from establishing a single source of truth.

Code Duplication

Instead of encapsulating rules and constraints within a specific type representing the concept, they are scattered throughout the codebase whenever the concept is required.

class UserController extends ExampleController
{
    public function create(string $id, string $email)
    {
        if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException('Invalid Email Address');
        }

        $user = new User($id, $email);
        $user->save();
    }

    public function update(string $id, string $email)
    {
        if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException('Invalid Email Address');
        }

        $user = User::findById($id);
        $user->email = $email;
        $user->save();
    }
}

Code duplication increases the risk of errors and mistakes by hindering maintainability.

Functionality spread across a codebase is challenging to update or modify because there is no single source of truth.

#Understanding

Recognizing the symptoms of Primitive Obsession is a good starting point.

However, before we discuss solutions, let's delve deeper to understand what these symptoms indicate because the drawbacks of Primitive Obsession aren't always obvious.

When a concept is represented using a primitive type, we are accepting all possible values the type supports.

We must consider the entire range of possible values for the primitive type and the valid values for our business domain.

When we define $email as a string, we permit:

  • Empty strings.
  • Strings with more than 320 characters.
  • Strings missing local or domain parts.
  • Just anything that isn't a valid email address.

We must ask ourselves:

Are all valid strings also valid email addresses?

Since using a string would mean our system supported the invalid values above, it's evident it's not suitable to represent our concept.

Therefore, using a string to represent $email would introduce Primitive Obsession.

#Value Objects

Since code smells indicate a specific underlying issue, are there solutions for each type of code smell?

Yes!

Value Objects resolve Primitive Obsession.

Our focus isn't on Value Objects themselves, but on how to use them to tackle Primitive Obsession. If you're unfamiliar with Value Objects, I've added a list of resources at the end of this section.

Parameter/Argument Order

Value Objects help avoid issues related to parameter and argument ordering by removing type ambiguity.

final class User
{
    public function __construct(UserId $userId, EmailAddress $email)
    {
    }
}

$user = new User(
    new EmailAddress('[email protected]'),
    new UserId('abc123')
);

Previously, both $userId and $email were typed as a string, making it possible to accidentally pass one as the other. This is no longer the case. Now, in order to successfully create an instance of User, we must provide a valid instance of a UserId and an EmailAddress.

We can also rely on these Value Objects. They are dependable because they incorporate business rules. If the rules were violated, their construction would have failed.

Encapsulation

Value Objects resolve our problem with behavior existing outside of our type by encapsulating related behavior and data together.

This advantage over primitive types not only gives us a place for related behavior, but also helps us protect the creation of the type.

final readonly class EmailAddress
{
    private string $value;

    public function __construct(string $value)
    {
        if (! filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException('Invalid Email Address');
        }

        $this->value = $value;
    }
}

We can now ensure that our value enforces our business rules and that the type is consistently valid.

Code Duplication

When we encapsulate state with behavior, as demonstrated above, we establish a Single Source of Truth. The EmailAddress class has a single responsibility: to represent a valid email address.

Value Objects eliminate the need to duplicate logic each time a type is used. It enables us to consolidate logic related to the type within a single reusable class, promoting consistency and reducing code repetition across the codebase.

class UserController extends ExampleController
{
    public function create(string $id, string $email)
    {
        $user = new User(
            new UserId($id),
            new EmailAddress($email)
        );

        $user->save();
    }

    public function update(string $id, string $email)
    {
        $user = User::findById($id);
        $user->email = new EmailAddress($email);
        $user->save();
    }
}

Introducing Value Objects also reduces Feature Envy (another code smell) by applying the Tell, Don’t Ask principle. If you'd like to know more about this, be sure to subscribe to my newsletter below.

Complex Types

An EmailAddress type can be quite simplistic. But where Value Objects can shine is their ability to represent complex types containing multiple pieces of data.

  • An Address typically contains a street, city, state, postal code, and country.

  • A TimeInterval might represent a period of time and contain attributes for start time and end time.

  • A GeoLocation could contain latitude and longitude coordinates.

  • A PersonName might contain attributes for first name, middle name, and last name.

  • A CreditCard could contain attributes such as card number, cardholder name, expiration date, and CVV.

$fiver = new Money(500, new Currency('USD'));

You didn’t think you were going to read a post related to types and Value Objects without an example with Money, did you?


More on Value Objects