← Назад к статьям
Архитектура PHP и паттерны проектирования

Value Objects в PHP: перестаньте писать код, одержимый примитивами

Узнайте, как Value Objects устраняют одержимость примитивами в PHP, с реальными примерами классов Email и Money, приносящими типобезопасность, валидацию и ясность в ваш доменный код.

Доступные языки
Value Objects в PHP: перестаньте писать код, одержимый примитивами
Рекомендуемая статья ↗

Value Objects в PHP: перестаньте писать код, одержимый примитивами

Посмотрите почти на любую кодовую базу PHP, и вы найдёте один и тот же паттерн: email хранится как строка, деньги — как float, ID — как целое число, номера телефонов — как строки. Это называется primitive obsession (одержимость примитивами) — чрезмерное использование примитивных типов для представления доменных концепций. Это один из самых распространённых и самых разрушительных антипаттернов в современной PHP-разработке.

Value Objects — это лекарство. Они приносят типобезопасность, валидацию и ясность в ваш код — и как только вы начнёте их использовать, вы удивитесь, как жили без них раньше.

Что такое Value Object?

Value Object (VO, объект-значение) — это небольшой иммутабельный объект, который представляет концепцию из вашего домена. В отличие от Entity, Value Object не имеет идентичности — он полностью определяется своими значениями. Два Value Object с одинаковыми значениями считаются равными.

Классические примеры включают:

  • Email — вместо сырой строки
  • Money — вместо float плюс строка валюты
  • UserId — вместо целого числа
  • PhoneNumber — вместо строки
  • DateRange — вместо двух объектов DateTime
  • Address — вместо массива строк
  • Password — вместо простой строки

Проблема с примитивами

Рассмотрим этот типичный код:

<?php function registerUser(string $email, float $balance): void { // $email валиден? Кто знает. // $balance в USD, EUR или BTC? Понятия не имею. // Может ли $balance быть отрицательным? Возможно. }

Каждое примитивное значение несёт скрытые допущения. Система типов ничего не говорит вам о том:

  • Валидно ли значение
  • В каком оно формате
  • Какие операции разрешены
  • Какие единицы или валюту оно представляет
  • Можно ли его безопасно изменить или переиспользовать

Результат? Логика валидации разбросана повсюду, баги из-за несовпадающих форматов и код, о котором трудно рассуждать.

Ваш первый Value Object: Email

Заменим примитивную строку на полноценный Value Object:

<?php declare(strict_types=1); final class Email { private function __construct( private readonly string $value, ) { } public static function fromString(string $value): self { $normalized = mb_strtolower(trim($value)); if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) { throw new InvalidArgumentException( sprintf('"%s" is not a valid email address.', $value) ); } return new self($normalized); } public function toString(): string { return $this->value; } public function equals(self $other): bool { return $this->value === $other->value; } public function __toString(): string { return $this->value; } }

Теперь система типов защищает вас. Если у вас есть объект Email, вы знаете, что он валиден. Больше никаких разбросанных проверок валидации.

Использование Value Object

<?php final class User { public function __construct( public readonly UserId $id, public readonly Email $email, public readonly string $name, ) { } } // Теперь конструктор обеспечивает валидность: $user = new User( id: UserId::fromInt(42), email: Email::fromString('John@Example.com'), name: 'John Doe', ); echo $user->email->toString(); // "john@example.com" (нормализовано)

Более сложный пример: Money

Деньги — классический случай, когда примитивы терпят катастрофу. Float теряет точность, а смешивание валют — это тихий баг, который только и ждёт своего часа.

<?php declare(strict_types=1); final class Money { private function __construct( private readonly int $amountInCents, private readonly string $currency, ) { if ($amountInCents < 0) { throw new InvalidArgumentException('Amount cannot be negative.'); } if (!preg_match('/^[A-Z]{3}$/', $currency)) { throw new InvalidArgumentException('Currency must be a 3-letter ISO code.'); } } public static function of(int $amountInCents, string $currency): self { return new self($amountInCents, $currency); } public static function fromFloat(float $amount, string $currency): self { return new self((int) round($amount * 100), $currency); } public function add(self $other): self { $this->assertSameCurrency($other); return new self( $this->amountInCents + $other->amountInCents, $this->currency, ); } public function subtract(self $other): self { $this->assertSameCurrency($other); return new self( $this->amountInCents - $other->amountInCents, $this->currency, ); } public function isGreaterThan(self $other): bool { $this->assertSameCurrency($other); return $this->amountInCents > $other->amountInCents; } public function amount(): int { return $this->amountInCents; } public function currency(): string { return $this->currency; } public function format(): string { return number_format($this->amountInCents / 100, 2) . ' ' . $this->currency; } public function equals(self $other): bool { return $this->amountInCents === $other->amountInCents && $this->currency === $other->currency; } private function assertSameCurrency(self $other): void { if ($this->currency !== $other->currency) { throw new InvalidArgumentException( sprintf('Cannot operate on %s and %s.', $this->currency, $other->currency) ); } } }

Теперь посмотрите, насколько чистым становится вызывающий код:

<?php $price = Money::fromFloat(19.99, 'USD'); $tax = Money::fromFloat(1.60, 'USD'); $total = $price->add($tax); echo $total->format(); // "21.59 USD" // Это выбросит исключение вместо тихого повреждения данных: $price->add(Money::of(500, 'EUR'));

Ключевые характеристики хорошего Value Object

  • Иммутабельный — будучи созданным, он никогда не меняется. Операции возвращают новые экземпляры.
  • Самовалидирующийся — экземпляр не может существовать в невалидном состоянии.
  • Без идентичности — равенство основано на значениях, а не на ID в базе данных.
  • Без побочных эффектов — никаких запросов к базе данных, логирования, глобального состояния.
  • Маленький — представляет одну концепцию, а не целый агрегат.
  • Заменяемый — весь объект можно заменить, а не мутировать поле за полем.

Value Object vs. DTO vs. Entity

Важно различать эти три паттерна:

  • Value Object — иммутабельный, определяется значениями, содержит поведение, связанное с этими значениями (например, add() у Money).
  • DTO — иммутабельный или мутабельный, переносит данные между слоями, не содержит бизнес-логики.
  • Entity — имеет уникальную идентичность, которая сохраняется во времени, даже когда её атрибуты меняются.

Где Value Objects блистают

  • Доменные модели — представление email, денег, ID, дат, адресов.
  • Валидация ввода — создание VO из пользовательского ввода гарантирует валидность везде дальше по цепочке.
  • Бизнес-правила — инкапсуляция логики, такой как сопоставление валют или проверка диапазонов дат.
  • Тестирование — более простые, быстрые и сфокусированные тесты без зависимостей от базы данных.
  • Рефакторинг — когда требования меняются, вы меняете VO в одном месте.

Распространённые ошибки, которых стоит избегать

  • Добавление сеттеров — это нарушает иммутабельность. Если нужно другое значение, создайте новый экземпляр.
  • Делать их слишком большими — VO должен представлять одну концепцию, а не целый агрегатный корень.
  • Добавление логики персистентности — держите заботы о базе данных в репозиториях, а не в VO.
  • Игнорирование равенства — всегда реализуйте метод equals().
  • Выброс общих исключений — определяйте доменно-специфичные исключения для более ясной обработки ошибок.

Практический путь рефакторинга

Вам не нужно переписывать всё приложение за одну ночь. Начните здесь:

  1. Определите примитивное поле, которое вызывает баги или путаницу (email, деньги, ID).
  2. Создайте для него Value Object с валидацией и полезными методами.
  3. Замените примитив в одном классе или одном модуле.
  4. Позвольте компилятору и вашим тестам направлять остальное.
  5. Повторите со следующим очевидным кандидатом.

Заключение

Value Objects — один из самых высокоэффективных рефакторингов, которые вы можете сделать в кодовой базе PHP. Они устраняют одержимость примитивами, централизуют валидацию, делают невозможным представление невалидных состояний и превращают ваш код в самодокументируемое выражение вашего домена. Система типов становится союзником, а не формальностью.

Начните с одного Value Object — может быть, Email или Money. Как только вы увидите, сколько ясности это приносит в ваш код, вы никогда не вернётесь к передаче сырых строк.

Перестаньте писать код, одержимый примитивами. Позвольте вашим типам говорить за ваш домен.

Технологии и темы

Теги статьи

Нет статей, соответствующих этим фильтрам.

Есть проект или идея для обсуждения?

Давайте обсудим ↗