Value objects over primitive obsession
A string $email can hold "not-an-email" all the way to your database. The
type system isn't lying to you - it's just not telling you the truth you need.
Bad - the invariant lives nowhere
final class RegisterUser
{
public function __construct(
private readonly UserRepository $users,
) {}
public function handle(string $email, string $password): void
{
if (!str_contains($email, '@')) {
throw new InvalidArgumentException('Invalid email');
}
$this->users->save(new User($email, $password));
}
}
The check is correct today. It also has to be remembered, and re-implemented, everywhere else an email address enters the system - a second use case, an admin import script, a console command. Miss one and the invariant is gone.
Good - the invariant is the type
final class EmailAddress
{
private function __construct(private readonly string $value) {}
public static function fromString(string $value): self
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidEmailAddress($value);
}
return new self($value);
}
public function __toString(): string
{
return $this->value;
}
}
final class RegisterUser
{
public function __construct(
private readonly UserRepository $users,
) {}
public function handle(EmailAddress $email, Password $password): void
{
$this->users->save(new User($email, $password));
}
}
EmailAddress can't exist in an invalid state - construction is validation.
Every call site downstream, including ones written six months from now by
someone who hasn't read this file, gets the guarantee for free.
The tradeoff: more classes, more ceremony for what looks like a string. Worth it exactly where the invariant is business-critical, not for every field on every form.