Primitive obsession: introduce domain types
Give a value its own type, so its rules live in one place.
Watch the narrated video
Every step
Problem
Steps 1–4Refactor
Steps 5–8Payoff
Steps 9–10Recap
Steps 11–12The code: before and after
Before
final class AccountService
{
/** @var array<string, string> */
private array $emailById = [];
public function register(string $customerId, string $email): void
{
if (!str_contains($email, '@')) {
throw new InvalidArgumentException($email);
}
$this->emailById[$customerId] = strtolower($email);
}
public function changeEmail(string $customerId, string $newEmail): void
{
if (!str_contains($newEmail, '@')) {
throw new InvalidArgumentException($newEmail);
}
$this->emailById[$customerId] = $newEmail;
}
public function findCustomer(string $email): ?string
{
$wanted = strtolower($email);
foreach ($this->emailById as $id => $stored) {
if ($stored === $wanted) {
return $id;
}
}
return null;
}
}
final class SignupController
{
public function __construct(private AccountService $accounts) {}
public function onSignup(string $email, string $customerId): void
{
$this->accounts->register($customerId, $email);
}
}
final class SupportDesk
{
public static function run(AccountService $accounts): ?string
{
$accounts->register('c1', 'ana@shop.com');
$accounts->changeEmail('c1', 'Ana@Shop.com');
return $accounts->findCustomer('ana@shop.com');
}
}After
final readonly class EmailAddress
{
public string $value;
public function __construct(string $value)
{
$value = trim($value);
if (!str_contains($value, '@')) {
throw new InvalidArgumentException($value);
}
if (!str_contains(strstr($value, '@'), '.')) {
throw new InvalidArgumentException($value);
}
$this->value = strtolower($value);
}
}
final readonly class CustomerId
{
public function __construct(public string $value) {}
}
final class AccountService
{
/** @var array<string, EmailAddress> */
private array $emailById = [];
public function register(CustomerId $customerId,
EmailAddress $email): void
{
$this->emailById[$customerId->value] = $email;
}
public function changeEmail(CustomerId $customerId,
EmailAddress $newEmail): void
{
$this->emailById[$customerId->value] = $newEmail;
}
public function findCustomer(EmailAddress $email): ?CustomerId
{
foreach ($this->emailById as $id => $stored) {
if ($stored == $email) {
return new CustomerId($id);
}
}
return null;
}
}
final class SignupController
{
public function __construct(private AccountService $accounts) {}
public function onSignup(string $email, string $customerId): void
{
$this->accounts->register(new CustomerId($customerId),
new EmailAddress($email));
}
}
final class SupportDesk
{
public static function run(AccountService $accounts): ?CustomerId
{
$c1 = new CustomerId('c1');
$accounts->register($c1, new EmailAddress('ana@shop.com'));
$accounts->changeEmail($c1, new EmailAddress('Ana@Shop.com'));
return $accounts->findCustomer(new EmailAddress('ana@shop.com'));
}
}Transcript, step by step
Problem
Step 1: Accounts keyed by strings Before
This account service keeps every customer's email in an array, keyed by customer ID. Register checks that the address has an at sign and stores it in lower case, and change email checks it too. Find customer lowercases the address you ask for and looks for a matching email in the array. The signup controller hands a new customer's email and ID to register. And the support desk registers Ana, changes her email, then looks her up by address.
Step 2: Trim it, three times Code smell
Support says customers paste emails with spaces around them, so trim them, and reject an address with no dot after the at sign. Primitive obsession is using a plain string for an idea that has its own rules, so every method that touches the string must remember them. You can see it here in register, here in change email, and here in find customer. Each of them had to learn the new rule, and the next rule about emails will cost the same three edits.
Step 3: Already drifted Code smell
And the copies have already drifted, because change email stores the address without lowercasing it. So when the support desk changes Ana's email to a capitalized address, the lookup in lower case comes back empty. The customer ID and the email are both strings, so PHP accepts them in either order, and so does PHPStan. Look at the signup call, which takes the email first and hands it to register, which wants the ID first. Swap them by mistake, and nothing fails until a lookup returns the wrong customer.
Step 4: Back it out, then add types Plan
Let's back that change out. First the email and the customer ID get types of their own, so the email rule has one home, and then the trim and the dot check go in there.
Refactor
Step 5: The email gets a type Replace Primitive with Object
We start with the email, as a readonly class called Email Address that holds one string value. Register, change email and find customer take an Email Address now, and the array stores them too. The checks stay where they were for now, and they read the string through the value property. The signup controller and the support desk wrap their strings at the call, so the program does the same as before.
Step 6: The @ check moves in Move Statements into Function
The at sign check was the same in both methods, so it moves into the Email Address constructor. Register and change email lose their checks, and an address without an at sign can't even be built. One more thing changes here, because a lookup for an invalid address like bob used to come back empty, and now it throws when its Email Address is built.
Step 7: Lowercase moves in Move Statements into Function
Lowercasing is the other half of the email rule, so it moves into the constructor too. PHP won't let a constructor overwrite a promoted readonly property, so value becomes a plain property that the constructor sets once. Register stores the address it's given, and find customer compares two Email Addresses with double equals. Double equals compares two objects of the same class property by property, so two addresses with the same text are equal, which makes Email Address a value object. This step changes one behavior on purpose, because change email now stores a lowercased address too, and that fixes the drift. So the support desk's lookup finds Ana's ID again.
Step 8: The ID gets a type Replace Primitive with Object
The customer ID gets the same treatment, as a readonly class called Customer ID. Register, change email and the result of find customer all use it. The array keys on the ID's value, because a PHP array key can only be a string or an integer. The signup controller builds both types, and the support desk names its customer once. Now if you swap the two arguments, PHP throws a type error at the call, and PHPStan reports it before the code ever runs.
Payoff
Step 9: Trim and dot, one edit Payoff
Now the trim and the dot rule go back in. The constructor trims the value first, then checks for an at sign and a dot after it, and lowercases last. The account service and the signup controller didn't change. Register, change email and find customer hold no email rule now, and the support desk's calls find Ana's ID again.
Step 10: Why the types paid off Payoff
The email rule used to live in three methods and had already drifted, so now one constructor owns it. A method can't forget the rule, because every Email Address it receives went through that constructor. And the customer ID and the email can't trade places, because PHP checks each parameter's type, and PHPStan catches a swap before anything runs.
Recap
Step 11: Where we started, where we ended Before / after
On the left, every method took two strings, and the customer ID and the email could trade places. The at sign check was written twice, and the lowercase was in register and find customer but missing from change email. On the right, Email Address owns the whole rule in its constructor, and Customer ID gives the ID a type of its own. And find customer compares whole Email Address values, with no email rule of its own.
Step 12: When to reach for it Takeaways
Reach for a domain type when a value has rules that more than one method checks, or when two values of the same type are easy to swap. Skip it for a string with no rules that passes through once, like a delivery note, because a wrapper adds a type and gives you nothing back. You'll spot it by the same check repeated across methods, several strings in one signature, and names that carry the format. PHP already does this with Date Time Immutable, which you build once from a date string, so no method has to parse that string again.