У матеріалі про те, де має жити бізнес-правило я писала, що важливе правило краще тримати біля стану, який воно захищає.
З Value Object у мене була дуже схожа історія. Спочатку майже все виглядало як звичайний string: код продукту, email, версія, зовнішній ідентифікатор.
Проблема починалася не через сам тип string. Вона з’являлася тоді, коли навколо значення накопичувалися правила: привести до одного формату, перевірити допустимість, правильно порівняти або не дозволити переплутати два різні значення однакового технічного типу.
Саме тоді primitive string переставав бути достатнім.
Звичайний string не є проблемою сам по собі
final class Product{ public function __construct( private string $name, private string $description, ) { }}Якщо значення лише зберігається і відображається, окремий клас може не дати нічого корисного.
DDD для мене не означає створювати клас на кожне поле. Я чекаю сигналу, що значення почало нести більше відповідальності, ніж просто "це текст".
Перший сигнал: значення має власну нормалізацію
$email = strtolower( trim($email));Спочатку така нормалізація виглядає настільки просто, що окремий тип здається зайвим.
Потім той самий trim() і strtolower() з’являються у Form Request, імпорті, сервісі та тестах.
У цей момент мені вже корисніше мати один об’єкт, який завжди створює значення в однаковій формі.
Email стає корисним Value Object, коли правила мають бути однаковими всюди
final readonly class Email{ private function __construct( public string $value, ) { } public static function fromString( string $value, ): self { $value = strtolower( trim($value) ); if ( filter_var(Тепер код, який отримав Email, уже не повинен запитувати, чи значення було нормалізоване.
Сам факт існування об’єкта означає, що базова перевірка вже пройдена.
Для мене це одна з головних переваг Value Object: він робить припущення коду явними.
ProductCode додає семантику, якої немає у string
final readonly class ProductCode{ public function __construct( public string $value, ) { if ( ! preg_match( '/^[A-Z0-9][A-Z0-9_-]{2,31}$/', $value, ) ) { throw new InvalidArgumentException( 'Invalid product code.', ); } }string $code майже нічого не говорить про зміст параметра.
ProductCode $code уже передає семантику в самій сигнатурі. Також неможливо створити ProductCode, який не відповідає його правилам.
Окремий тип захищає від переплутаних рядків
public function assign( string $productCode, string $externalIdentifier,): void { // Both are strings.}public function assign( ProductCode $productCode, ExternalIdentifier $externalIdentifier,): void { // Their meaning is explicit.}Коли в методі кілька аргументів типу string, правильний порядок часто тримається в голові.
Коли важливі значення мають власні типи, частину цієї перевірки бере на себе код.
Money показує, чому одного числа часто недостатньо
final readonly class Money{ public function __construct( public int $minorUnits, public string $currency, ) { if ($minorUnits < 0) { throw new InvalidArgumentException( 'Amount cannot be negative.', ); } if (strlen($currency) !== 3) { throw new InvalidArgumentException( 'Invalid currency.', );1000 саме по собі не пояснює валюту або одиницю.
Money об’єднує суму з валютою і правилами операцій. У такому випадку Value Object уже не просто обгортка над int.
Version корисний, коли порівняння має доменний зміст
final readonly class Version{ public function __construct( public string $value, ) { if ( ! preg_match( '/^\d+\.\d+\.\d+$/', $value, ) ) { throw new InvalidArgumentException( 'Invalid version.', ); } }Версія технічно може бути рядком, але звичайне порівняння рядків не передає потрібної поведінки.
Коли правило порівняння є частиною самого значення, окремий тип робить код виразнішим.
Value Object визначається значенням, а не власною identity
$emailA = Email::fromString( 'USER@example.com',); $emailB = Email::fromString( 'user@example.com',); // Same normalized value.// They represent the same email.Entity важлива тим, хто саме це є. Value Object важливий тим, яке значення він представляє.
Два Email з однаковим нормалізованим значенням для предметної області означають те саме. Тому такі об’єкти мені зручно робити незмінними.
Незмінність робить Value Object простішим
Якщо Email уже пройшов перевірку, я не хочу, щоб хтось пізніше зробив $email->value = "invalid".
readonly добре відповідає природі Value Object. Якщо значення потрібно змінити, створюється новий об’єкт і знову проходить правила створення.
Value Object допомагає не складати дрібні правила в Service
У матеріалі про Application Service і Domain Service я писала, що слово Service не повинно ставати місцем для всієї логіки.
Нормалізація ProductCode або порівняння Version не потребують окремого SomethingService, якщо вони природно належать самому значенню.
Form Request і Value Object можуть перевіряти різні речі
Form Request: - поле присутнє- вхідні дані є рядком- максимальна довжина запиту Value Object: - формат домену є коректним- застосовано нормалізацію- значення може існувати в доменіЯ не використовую Value Object як заміну всій перевірці HTTP-запиту.
Form Request перевіряє форму вхідних даних, а Value Object гарантує, що після створення значення відповідає правилам предметної області.
У базі Value Object все одно може залишатися звичайним scalar
Domain: ProductCode об'єктEmail об'єктVersion об'єкт Database: VARCHARVARCHARVARCHARValue Object не означає, що база повинна зберігати serialized PHP object.
ProductCode може залишатися VARCHAR. На межі persistence значення просто перетворюється між scalar і Value Object.
У Laravel перетворення можна сховати за cast або mapper
protected function casts(): array{ return [ 'product_code' => ProductCodeCast::class, ];}У конкретному проєкті це може бути Eloquent cast, repository mapper або явне перетворення.
Для мене важливіше не те, який механізм використаний, а те, щоб правила Value Object не розповзалися назад по контролерах і сервісах.
Я не замінюю всі primitive типи на класи
Інакше легко отримати десятки типів, які лише зберігають одне поле і не мають жодного правила.
Такий код може виглядати дуже "DDD", але бути важчим без реальної користі.
Я створюю Value Object, коли він прибирає повторення, захищає допустиме значення, додає важливу семантику або переносить природну поведінку ближче до самого поняття.
У AVN Center потреба в окремих типах з’являлася разом із правилами
У роботі над AVN Center я не намагалася наперед визначити всі можливі Value Object.
Вони ставали помітними тоді, коли звичайне значення починало мати власну перевірку, порівняння або окремий зміст у моделі.
Це схоже на те, що я описувала в матеріалі про межі домену в системі, що росте: модель уточнюється разом із нашим розумінням предметної області.
Тут я навмисно використовую загальні приклади ProductCode, Email, Money, Version та Identifier, без внутрішніх типів майбутнього продукту.
Коли я думаю про Value Object
Я ставлю кілька простих питань:
- Чи це значення має власні правила перевірки?
- Чи його потрібно завжди нормалізувати однаково?
- Чи в нього є власні правила порівняння або операцій?
- Чи два однакові primitive типи мають різний доменний зміст?
- Чи повторюється одна й та сама логіка навколо значення в кількох місцях?
- Чи тип у сигнатурі зробить код зрозумілішим без пояснювального коментаря?
- Чи можна гарантувати допустимість значення в момент створення?
Value Object з’являється тоді, коли значення перестає бути просто значенням
Для мене Value Object не є обов’язковою частиною кожного DDD-проєкту і точно не потрібен для кожного поля.
Я використовую його тоді, коли primitive тип уже приховує важливі припущення.
ProductCode має формат. Email має нормалізацію і перевірку. Money має валюту і правила операцій. Version має власне порівняння. Identifier може не дозволяти переплутати два різні поняття.
Коли разом зі значенням з’являються правила, семантика або поведінка, окремий тип перестає бути зайвою обгорткою і стає частиною моделі.
Primitive тип достатній, поки значення не має власних правил. Коли правила з’являються, Value Object допомагає зробити їх видимими в самій моделі.



