0% прочитано

Value Object у реальному проєкті: коли primitive string уже недостатньо

Рядок достатній, поки значення не має власних правил. Коли разом із ним з’являються нормалізація, перевірка, порівняння або окрема семантика, Value Object допомагає зробити ці правила явними й не розкидати їх по сервісах.

16 жовтня 2026 р. 11 хв читанняЛаравель

У матеріалі про те, де має жити бізнес-правило я писала, що важливе правило краще тримати біля стану, який воно захищає.

З Value Object у мене була дуже схожа історія. Спочатку майже все виглядало як звичайний string: код продукту, email, версія, зовнішній ідентифікатор.

Проблема починалася не через сам тип string. Вона з’являлася тоді, коли навколо значення накопичувалися правила: привести до одного формату, перевірити допустимість, правильно порівняти або не дозволити переплутати два різні значення однакового технічного типу.

Саме тоді primitive string переставав бути достатнім.

Я не створюю Value Object для кожного рядка. Він стає корисним тоді, коли значення має власну семантику або власні правила.

Звичайний 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: VARCHARVARCHARVARCHAR

Value 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 допомагає зробити їх видимими в самій моделі.