<? phpukraine СПІВБЕСІДИ
Пошук по платформі
АРХІТЕКТУРА · MIDDLE ЧАСТО ПИТАЮТЬ

Що таке value object і навіщо він, якщо є скалярні типи?

Value object — незмінний обʼєкт без ідентичності, який описується лише своїм значенням: він валідує себе в конструкторі, тому в системі не існує невалідного екземпляра, і порівнюється за вмістом, а не за посиланням.

Чим `Money` кращий за `float $amount` і `string $currency` поруч?
У нас email валідується у трьох місцях по-різному — як це лікувати?
Value object і entity: у чому різниця, крім слова «immutable»?
Як порівняти два value object і чому `===` тут не працює?
value object immutability DDD primitive obsession readonly

Скалярний тип описує форму даних, але нічого не каже про їхній зміст. string $email — це будь-який рядок, зокрема порожній, з пробілом або з двома собаками; float $price — будь-яке число, зокрема відʼємне й з похибкою округлення. Тому валідація розповзається: перевірка у формі, ще одна в сервісі, третя перед відправкою в платіжний шлюз, і всі трохи різні. Value object згортає це в одну точку: інваріант перевіряється в конструкторі, а якщо конструктор відпрацював — невалідного екземпляра в системі не існує. Далі тип у сигнатурі працює як доказ: побачивши Email $to, ви вже знаєте, що всередині не сміття, і повторна перевірка зайва.

Друга властивість — відсутність ідентичності. Value object повністю визначається своїм вмістом: двісті гривень нічим не відрізняються від інших двохсот гривень, тому їх можна вільно замінювати одне одним. Через це VO роблять незмінним: у PHP це readonly-властивості з 8.1 або readonly class з 8.2, а «зміна» повертає новий екземпляр (add(), withCurrency()). Незмінність прибирає цілий клас багів із розділюваним станом: обʼєкт, переданий у три сервіси, не може бути зіпсований одним із них. Памʼятайте про межу readonly: воно захищає саме посилання, тож обʼєкт усередині VO теж має бути незмінним, інакше гарантія фіктивна.

Третя властивість — рівність за значенням, і саме тут найчастіше помиляються. === для обʼєктів порівнює ідентичність, тому два однакові Money будуть нерівні. == порівнює клас і всі властивості рекурсивно, що для простого VO спрацює, але залежить від внутрішніх деталей і вкладених обʼєктів. Тому пишуть явний equals(), який ще й фіксує правило: наприклад, для Email домен зазвичай нечутливий до регістру, і це рішення має бути в коді, а не в чиїйсь голові. Окремо: PHP не дозволяє обʼєкт як ключ масиву, тож для дедуплікації VO беруть його рядкове представлення.

Практичний виграш крім валідації — поведінка, якій нарешті є куди переїхати. Форматування суми, конвертація валют, порівняння цін, нормалізація телефону — усе це раніше жило статичними хелперами й дублювалося; у VO воно лежить поруч із даними, які описує, і покривається тестами без бази. Типовий кандидат: гроші, email, телефон, слаг, період дат, координати, ідентифікатор із форматом (наприклад, IBAN або ЄДРПОУ). Ознака, що VO потрібен: значення мандрує через кілька шарів і в кожному його перевіряють чи форматують заново.

Ціна теж реальна. VO треба мапити на сховище: у Doctrine ORM це #[Embeddable] плюс #[Embedded] у сутності або власний DBAL-тип, в Eloquent — кастомний каст на CastsAttributes, чий set() може повернути масив і розкласти обʼєкт у кілька колонок. Його треба серіалізувати на межах системи: у JSON-відповіді, у payload черги, у форму — тож toString()/fromString() пишуться одразу. І VO не безкоштовний за памʼяттю, тому в гарячих циклах на сотні тисяч ітерацій скаляр із перевіркою на вході чесніший. Правило межі просте: обгортка без інваріанта — це шум, а обгортка з інваріантом окупається з першого ж місця, де ви змогли викинути валідацію.

/**
 * Гроші: сума в мінорних одиницях (копійки), валюта — enum.
 * float заборонений: 0.1 + 0.2 !== 0.3, а гроші мусять сходитись до копійки.
 */
final readonly class Money                       // readonly class — PHP 8.2+
{
    private function __construct(
        public int $amount,                      // копійки, не гривні
        public Currency $currency,               // enum, PHP 8.1+
    ) {
        // Єдине місце, де перевіряється інваріант: далі тип сам є гарантією
        if ($amount < 0) {
            throw new InvalidArgumentException('Сума не може бути відʼємною');
        }
    }

    /** Іменований конструктор: назва пояснює одиниці виміру */
    public static function fromMinorUnits(int $amount, Currency $currency): self
    {
        return new self($amount, $currency);
    }

    /** «Зміна» — це новий екземпляр, старий лишається недоторканим */
    public function add(self $other): self
    {
        $this->assertSameCurrency($other);

        return new self($this->amount + $other->amount, $this->currency);
    }

    /** === порівняв би ідентичність, тому рівність описуємо явно */
    public function equals(self $other): bool
    {
        return $this->amount === $other->amount
            && $this->currency === $other->currency;   // enum-кейси — синглтони
    }

    public function __toString(): string
    {
        return sprintf('%d.%02d %s', intdiv($this->amount, 100), $this->amount % 100, $this->currency->value);
    }

    private function assertSameCurrency(self $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new DomainException('Не можна додавати різні валюти');
        }
    }
}

// Сигнатура сама себе документує і не дає переплутати аргументи місцями
$total = Money::fromMinorUnits(19900, Currency::UAH)->add(Money::fromMinorUnits(5000, Currency::UAH));
Що VO не має ідентичності: два `Money(100, 'UAH')` взаємозамінні, а два `User` з однаковим імʼям — ні.
Що інваріант перевіряється один раз у конструкторі, тому тип `Email` у сигнатурі вже є гарантією, і повторна валідація в сервісах зайва.
Що незмінність досягається `readonly`-властивостями (PHP 8.1) або `readonly class` (8.2), а «зміна» — це новий екземпляр, а не мутація.
Що порівняння йде через власний `equals()`, бо `===` для обʼєктів порівнює ідентичність, а не вміст.
Що гроші зберігаються цілим числом у мінорних одиницях, а не `float`, і що VO — природне місце для цього правила.
Що VO треба мапити на сховище: Doctrine `#[Embedded]`, Laravel custom cast через `CastsAttributes`.
Називати VO будь-який DTO: DTO переносить дані без інваріантів, VO гарантує їх і порівнюється за значенням.
Валідувати в сеттері чи в статичному методі `isValid()`, залишаючи можливість створити невалідний обʼєкт напряму через конструктор.
Порівнювати через `===` (завжди false для різних екземплярів) або через `==` без розуміння, що воно рекурсивно порівнює всі властивості, зокрема вкладені обʼєкти.
Робити VO з `float` для грошей: `0.1 + 0.2 !== 0.3`, і VO лише ховає помилку за фасадом.
Загортати в VO геть усе, включно з булевими прапорцями й лічильниками, від чого код розбухає без жодної нової гарантії.
Додавати VO ідентифікатор чи `updated_at` — це вже entity, і зберігати його треба інакше.
ПОРАДА

Сформулюйте вигоду через сигнатуру: `charge(Money $amount, Email $to)` неможливо викликати з переплутаними аргументами й неможливо викликати з невалідними даними, а `charge(float $amount, string $currency, string $email)` — можна, і компілятор мовчатиме. Далі згадайте `equals()` і мапінг у базу — це відрізняє того, хто VO писав, від того, хто про них читав.

оновлено 5 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Ключові ознаки VO — відсутність ідентичності, незмінність, інваріант у конструкторі та рівність за вмістом; наявність id робить обʼєкт entity, а DTO не гарантує інваріантів.