Entity и Value Object: кто есть кто

Entity и Value Object: кто есть кто

Не все данные одинаковы: у одних есть «личность» и история, у других - только значение. Эта разница меняет, как ты пишешь код, тесты и валидацию. См. также структуры в Go.

Проблема: всё - структура с ID

В типичном Go-проекте все данные выглядят одинаково: структура с полями, ID у каждой. Email - строка, деньги - float64, slug - тоже строка. Валидация разбросана по хэндлерам и сервисам. Два email сравниваются через ==, а регистр не учитывается. Деньги складываются с потерей точности.

// Типичный «плоский» подход: всё - просто поля
type User struct {
    ID       int
    Name     string
    Email    string   // просто строка, никакой защиты
    Balance  float64  // деньги через float - рецепт катастрофы
}

// Валидация - где-то в хэндлере
func CreateUser(name, email string) (*User, error) {
    if !strings.Contains(email, "@") {  // дублируется в 5 местах
        return nil, errors.New("bad email")
    }
    return &User{Name: name, Email: email}, nil
}
// Типичный «плоский» подход: всё - просто поля
final class User
{
    public function __construct(
        public int $id,
        public string $name,
        public string $email,   // просто строка, никакой защиты
        public float $balance,  // деньги через float - рецепт катастрофы
    ) {}
}

// Валидация - где-то в контроллере
function createUser(string $name, string $email): User
{
    if (!str_contains($email, '@')) {  // дублируется в 5 местах
        throw new InvalidArgumentException('bad email');
    }
    return new User(0, $name, $email, 0.0);
}

DDD предлагает разделить данные на два типа: Entity (сущность) и Value Object (объект-значение). Это разделение фундаментально меняет архитектуру.

Entity сравнивается по ID (два User с разными именами, но одним id - один User); Value Object - по значению (два Email с одинаковой строкой равны)

Entity: объект с идентичностью

Entity - это объект, который уникален благодаря своему идентификатору, а не содержимому.

Два пользователя могут иметь одинаковые имя и email (однофамильцы с почтой на одном домене), но если у них разные ID - это разные пользователи. И наоборот: пользователь может сменить имя, email, аватарку - но он остаётся тем же пользователем, потому что ID не изменился.

Признаки Entity:

  • Есть уникальный идентификатор (ID, UUID).
  • Имеет жизненный цикл - создаётся, изменяется, может быть удалён.
  • Изменяемый (mutable) - поля могут меняться со временем.
  • Сравнивается по ID, а не по содержимому.

Entity в Go: User

package domain

import "time"

// User - Entity. Идентичность определяется полем ID.
type User struct {
    id        string
    email     Email      // Value Object (см. ниже)
    name      string
    createdAt time.Time
}

// NewUser - конструктор с валидацией
func NewUser(id string, email Email, name string) (*User, error) {
    if id == "" {
        return nil, errors.New("user id is required")
    }
    if name == "" {
        return nil, errors.New("user name is required")
    }
    return &User{
        id:        id,
        email:     email,
        name:      name,
        createdAt: time.Now(),
    }, nil
}

// Rename - изменение поля. Entity мутируется, но ID остаётся.
func (u *User) Rename(newName string) error {
    if newName == "" {
        return errors.New("name cannot be empty")
    }
    u.name = newName
    return nil
}

// ID - геттер. Поля приватные, доступ через методы.
func (u *User) ID() string        { return u.id }
func (u *User) Email() Email      { return u.email }
func (u *User) Name() string      { return u.name }

// Equals - Entity сравнивается по ID
func (u *User) Equals(other *User) bool {
    if other == nil {
        return false
    }
    return u.id == other.id
}
<?php
declare(strict_types=1);

namespace App\Learning\Domain;

// User - Entity. Идентичность определяется полем id.
// НЕ readonly: имя может меняться, но id остаётся.
final class User
{
    private function __construct(
        private readonly string $id,
        private readonly Email $email,
        private string $name,
        private readonly \DateTimeImmutable $createdAt,
    ) {}

    // create - конструктор-фабрика с валидацией
    public static function create(string $id, Email $email, string $name): self
    {
        if ($id === '') {
            throw new \InvalidArgumentException('user id is required');
        }
        if ($name === '') {
            throw new \InvalidArgumentException('user name is required');
        }
        return new self($id, $email, $name, new \DateTimeImmutable());
    }

    // rename - изменение поля. Entity мутируется, но id остаётся.
    public function rename(string $newName): void
    {
        if ($newName === '') {
            throw new \InvalidArgumentException('name cannot be empty');
        }
        $this->name = $newName;
    }

    public function id(): string { return $this->id; }
    public function email(): Email { return $this->email; }
    public function name(): string { return $this->name; }

    // equals - Entity сравнивается по id
    public function equals(self $other): bool
    {
        return $this->id === $other->id;
    }
}
В Go нет классов, но есть отличный паттерн: неэкспортированные поля + конструктор `NewXxx`. Никто не создаст `User` с пустым ID - конструктор не позволит. Это и есть защита инвариантов.

Value Object: объект без идентичности

Value Object (VO) - это объект, который определяется только своим содержимым. У него нет ID. Два VO с одинаковыми значениями - идентичны.

Пример из жизни: купюра в 100 рублей. Тебе не важен серийный номер - важна сумма. Любые две купюры по 100 рублей для тебя равны.

Признаки Value Object:

  • Нет уникального идентификатора.
  • Неизменяемый (immutable) - после создания поля не меняются.
  • Сравнивается по значениям - два VO с одинаковыми полями равны.
  • Самовалидирующийся - невалидный VO невозможно создать.

Value Object в Go: Email

package domain

import (
    "fmt"
    "strings"
)

// Email - Value Object. Определяется значением, а не ID.
type Email struct {
    value string // приватное поле - извне не изменить
}

// NewEmail - конструктор с валидацией.
// Невалидный Email невозможно создать.
func NewEmail(raw string) (Email, error) {
    v := strings.TrimSpace(strings.ToLower(raw))
    if v == "" {
        return Email{}, fmt.Errorf("email cannot be empty")
    }
    // Простая проверка. В реальном проекте - regexp или net/mail.
    parts := strings.Split(v, "@")
    if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
        return Email{}, fmt.Errorf("invalid email format: %s", raw)
    }
    if !strings.Contains(parts[1], ".") {
        return Email{}, fmt.Errorf("invalid email domain: %s", parts[1])
    }
    return Email{value: v}, nil
}

// String возвращает email как строку (для сериализации, логов)
func (e Email) String() string { return e.value }

// Equals - VO сравнивается по значению
func (e Email) Equals(other Email) bool {
    return e.value == other.value
}

// Domain возвращает домен email
func (e Email) Domain() string {
    parts := strings.Split(e.value, "@")
    return parts[1]
}
<?php
declare(strict_types=1);

namespace App\Learning\Domain;

// Email - Value Object. Определяется значением, а не ID.
// final readonly - иммутабельность гарантирована.
final readonly class Email
{
    private function __construct(
        private string $value,
    ) {}

    // fromString - фабрика с валидацией.
    // Невалидный Email невозможно создать.
    public static function fromString(string $raw): self
    {
        $v = strtolower(trim($raw));
        if ($v === '') {
            throw new \InvalidArgumentException('email cannot be empty');
        }
        $parts = explode('@', $v);
        if (count($parts) !== 2 || $parts[0] === '' || $parts[1] === '') {
            throw new \InvalidArgumentException('invalid email format: ' . $raw);
        }
        if (!str_contains($parts[1], '.')) {
            throw new \InvalidArgumentException('invalid email domain: ' . $parts[1]);
        }
        return new self($v);
    }

    public function value(): string { return $this->value; }

    // equals - VO сравнивается по значению
    public function equals(self $other): bool
    {
        return $this->value === $other->value;
    }

    // domain возвращает домен email
    public function domain(): string
    {
        return explode('@', $this->value)[1];
    }

    public function __toString(): string { return $this->value; }
}

Ключевой момент: NewEmail(" Admin@Gmail.COM ") и NewEmail("admin@gmail.com") создают одинаковые Value Objects. Нормализация происходит в конструкторе.

Value Object: Money

Деньги - классический Value Object. Никогда не используй float64 для денег.

package domain

import "fmt"

// Money - Value Object. Хранит сумму в минимальных единицах (копейки, центы).
type Money struct {
    amount   int64  // в копейках: 1050 = 10 руб 50 коп
    currency string // "RUB", "USD"
}

// NewMoney создаёт Money из суммы в копейках
func NewMoney(amountMinor int64, currency string) (Money, error) {
    if currency == "" {
        return Money{}, fmt.Errorf("currency is required")
    }
    if amountMinor < 0 {
        return Money{}, fmt.Errorf("amount cannot be negative")
    }
    return Money{amount: amountMinor, currency: currency}, nil
}

// Add складывает две суммы. Возвращает НОВЫЙ VO (иммутабельность).
func (m Money) Add(other Money) (Money, error) {
    if m.currency != other.currency {
        return Money{}, fmt.Errorf(
            "cannot add %s and %s", m.currency, other.currency,
        )
    }
    return Money{amount: m.amount + other.amount, currency: m.currency}, nil
}

// Rubles возвращает сумму в рублях (для отображения)
func (m Money) Rubles() float64 {
    return float64(m.amount) / 100
}

// Equals - VO сравнивается по всем полям
func (m Money) Equals(other Money) bool {
    return m.amount == other.amount && m.currency == other.currency
}

func (m Money) String() string {
    return fmt.Sprintf("%.2f %s", m.Rubles(), m.currency)
}
<?php
declare(strict_types=1);

namespace App\Learning\Domain;

// Money - Value Object. Хранит сумму в минимальных единицах (копейки, центы).
// final readonly - VO иммутабелен по контракту.
final readonly class Money
{
    private function __construct(
        private int $amount,     // в копейках: 1050 = 10 руб 50 коп
        private string $currency, // 'RUB', 'USD'
    ) {}

    public static function fromMinorUnits(int $amountMinor, string $currency): self
    {
        if ($currency === '') {
            throw new \InvalidArgumentException('currency is required');
        }
        if ($amountMinor < 0) {
            throw new \InvalidArgumentException('amount cannot be negative');
        }
        return new self($amountMinor, $currency);
    }

    public static function fromRubles(int $rubles): self
    {
        return self::fromMinorUnits($rubles * 100, 'RUB');
    }

    // add складывает две суммы. Возвращает НОВЫЙ VO (иммутабельность).
    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new \DomainException(
                sprintf('cannot add %s and %s', $this->currency, $other->currency)
            );
        }
        return new self($this->amount + $other->amount, $this->currency);
    }

    public function rubles(): float
    {
        return $this->amount / 100;
    }

    // equals - VO сравнивается по всем полям
    public function equals(self $other): bool
    {
        return $this->amount === $other->amount
            && $this->currency === $other->currency;
    }

    public function __toString(): string
    {
        return sprintf('%.2f %s', $this->rubles(), $this->currency);
    }
}
`0.1 + 0.2 = 0.30000000000000004` в любом языке с IEEE 754. Храни деньги в целых числах (копейки) - и проблемы не будет. Value Object Money защищает от этого на уровне типа.

Value Object: Slug

package domain

import (
    "fmt"
    "regexp"
    "strings"
)

var slugRegex = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*

  
    
    
    
    
    
    Entity и Value Object: кто есть кто | BackendStart.ru
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
      
    
    
  
  
    )

// Slug - Value Object для URL-friendly идентификаторов.
type Slug struct {
    value string
}

func NewSlug(raw string) (Slug, error) {
    v := strings.TrimSpace(strings.ToLower(raw))
    if v == "" {
        return Slug{}, fmt.Errorf("slug cannot be empty")
    }
    if !slugRegex.MatchString(v) {
        return Slug{}, fmt.Errorf("invalid slug format: %q (allowed: a-z, 0-9, hyphens)", raw)
    }
    return Slug{value: v}, nil
}

func (s Slug) String() string        { return s.value }
func (s Slug) Equals(other Slug) bool { return s.value == other.value }
<?php
declare(strict_types=1);

namespace App\Learning\Domain;

// Slug - Value Object для URL-friendly идентификаторов.
final readonly class Slug
{
    private const PATTERN = '/^[a-z0-9]+(-[a-z0-9]+)*$/';

    private function __construct(
        private string $value,
    ) {}

    public static function fromString(string $raw): self
    {
        $v = strtolower(trim($raw));
        if ($v === '') {
            throw new \InvalidArgumentException('slug cannot be empty');
        }
        if (preg_match(self::PATTERN, $v) !== 1) {
            throw new \InvalidArgumentException(
                'invalid slug format: ' . $raw . ' (allowed: a-z, 0-9, hyphens)'
            );
        }
        return new self($v);
    }

    public function value(): string { return $this->value; }
    public function equals(self $other): bool { return $this->value === $other->value; }
    public function __toString(): string { return $this->value; }
}

Entity vs Value Object: сравнение

КритерийEntityValue Object
ИдентичностьПо ID (UUID, int)По значениям полей
ИзменяемостьМутируется (поля меняются)Неизменяемый (immutable)
Жизненный циклСоздание -> изменение -> удалениеСоздание -> использование
Сравнениеa.ID() == b.ID()a.Equals(b) по всем полям
ПримерыUser, Course, OrderEmail, Money, Slug, DateRange
В базеСвоя таблица с PKПоле(я) в таблице Entity

Как решить: Entity или Value Object?

Задай себе три вопроса:

1. Нужно ли отслеживать этот объект по ID?

  • Пользователь - да, у него есть аккаунт, история -> Entity
  • Email пользователя - нет, важно только значение -> VO

2. Может ли объект измениться, оставаясь «тем же»?

  • Курс может обновить описание, но остаться тем же курсом -> Entity
  • Slug ddd-lite - если он изменился, это уже другой slug -> VO

3. Два объекта с одинаковым содержимым - это один объект?

  • Два пользователя с одинаковым именем - разные люди -> Entity
  • Два email admin@example.com - один и тот же email -> VO
Новички делают Entity из всего: EmailEntity, MoneyEntity, SlugEntity - с ID, таблицей в базе и полным CRUD. Это создаёт ненужную сложность. Если объект определяется значением - это Value Object, и ему не нужен ID.

Go и Value Objects: особенности языка

В Go нет классов и модификатора readonly. Но есть приёмы для реализации VO:

1. Приватные поля + конструктор

// Поля неэкспортированные - изменить извне нельзя
type Email struct {
    value string // маленькая буква = приватное
}
<?php
declare(strict_types=1);

// Поля private + final readonly - изменить извне нельзя
final readonly class Email
{
    public function __construct(
        private string $value, // private + readonly = иммутабельность
    ) {}
}

2. Методы на value receiver (не pointer)

// Value receiver - метод не может изменить структуру
func (e Email) String() string { return e.value }
func (e Email) Domain() string { ... }

// Это НЕ скомпилируется (если поле неэкспортированное):
// email.value = "hacked" - ошибка
<?php
declare(strict_types=1);

// readonly-свойство нельзя присвоить вне конструктора - аналог value receiver
final readonly class Email
{
    public function __construct(private string $value) {}

    public function __toString(): string { return $this->value; }
    public function domain(): string { return explode('@', $this->value)[1]; }
}

// Это бросит \Error на рантайме:
// $email->value = 'hacked'; -> Cannot modify readonly property

3. Операции возвращают новый VO

// Add не мутирует m - возвращает новый Money
func (m Money) Add(other Money) (Money, error) {
    return Money{amount: m.amount + other.amount, currency: m.currency}, nil
}
<?php
declare(strict_types=1);

// add не мутирует $this - возвращает новый Money
final readonly class Money
{
    public function __construct(private int $amount, private string $currency) {}

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new \DomainException('currency mismatch');
        }
        return new self($this->amount + $other->amount, $this->currency);
    }
}

4. Zero value - невалидный

// Email{} - пустой, невалидный. Это нормально.
// Единственный способ создать валидный Email - через NewEmail().
var e Email           // e.value == "" - невалидный
e, err := NewEmail("admin@example.com")  // единственный правильный путь
<?php
declare(strict_types=1);

// В PHP «zero value» нет - конструктор обязан получить аргументы.
// Защита: private __construct + статическая фабрика с валидацией.
final readonly class Email
{
    private function __construct(private string $value) {}

    public static function fromString(string $raw): self
    {
        $v = strtolower(trim($raw));
        if ($v === '' || !str_contains($v, '@')) {
            throw new \InvalidArgumentException('invalid email');
        }
        return new self($v);
    }
}

// Невалидный Email создать нельзя - конструктор приватный.
$email = Email::fromString('admin@example.com'); // единственный правильный путь

Полный пример: Entity + Value Objects вместе

Вот как Entity и Value Objects работают вместе в доменной модели BackendStart:

package domain

import (
    "errors"
    "time"
)

// Course - Entity. Учебный трек на платформе.
type Course struct {
    id           string
    slug         Slug         // Value Object
    title        string
    difficulty   Difficulty   // Value Object (enum)
    totalLessons int
    createdAt    time.Time
}

type Difficulty string

const (
    DifficultyBeginner     Difficulty = "beginner"
    DifficultyIntermediate Difficulty = "intermediate"
    DifficultyAdvanced     Difficulty = "advanced"
)

func NewCourse(id string, slug Slug, title string, difficulty Difficulty) (*Course, error) {
    if id == "" {
        return nil, errors.New("course id is required")
    }
    if title == "" {
        return nil, errors.New("course title is required")
    }
    return &Course{
        id:         id,
        slug:       slug,
        title:      title,
        difficulty: difficulty,
        createdAt:  time.Now(),
    }, nil
}

func (c *Course) ID() string         { return c.id }
func (c *Course) Slug() Slug         { return c.slug }
func (c *Course) Title() string      { return c.title }

// AddLesson увеличивает счётчик уроков. Entity мутируется.
func (c *Course) AddLesson() {
    c.totalLessons++
}

// Equals - Entity сравнивается по ID
func (c *Course) Equals(other *Course) bool {
    if other == nil {
        return false
    }
    return c.id == other.id
}
<?php
declare(strict_types=1);

namespace App\Learning\Domain;

// Difficulty - Value Object (enum) для уровня сложности
enum Difficulty: string
{
    case Beginner = 'beginner';
    case Intermediate = 'intermediate';
    case Advanced = 'advanced';
}

// Course - Entity. Учебный трек на платформе.
// НЕ readonly: totalLessons мутируется через addLesson().
final class Course
{
    private function __construct(
        private readonly string $id,
        private readonly Slug $slug,           // Value Object
        private readonly string $title,
        private readonly Difficulty $difficulty, // Value Object (enum)
        private int $totalLessons,
        private readonly \DateTimeImmutable $createdAt,
    ) {}

    public static function create(
        string $id,
        Slug $slug,
        string $title,
        Difficulty $difficulty,
    ): self {
        if ($id === '') {
            throw new \InvalidArgumentException('course id is required');
        }
        if ($title === '') {
            throw new \InvalidArgumentException('course title is required');
        }
        return new self($id, $slug, $title, $difficulty, 0, new \DateTimeImmutable());
    }

    public function id(): string { return $this->id; }
    public function slug(): Slug { return $this->slug; }
    public function title(): string { return $this->title; }

    // addLesson увеличивает счётчик уроков. Entity мутируется.
    public function addLesson(): void
    {
        $this->totalLessons++;
    }

    // equals - Entity сравнивается по id
    public function equals(self $other): bool
    {
        return $this->id === $other->id;
    }
}

Обрати внимание:

  • Course - Entity (есть id, мутируется через AddLesson).
  • Slug - Value Object (передаётся в конструктор, не меняется).
  • Difficulty - Value Object (enum через type + const).
  • Все поля приватные, доступ через геттеры.
  • Конструктор NewCourse валидирует инварианты.

Типичные ошибки и как их избежать

1. Примитивная одержимость (Primitive Obsession)

// ПЛОХО: email - просто string
func SendWelcome(email string) error { ... }
// Кто гарантирует, что email валиден? Никто.

// ХОРОШО: email - Value Object
func SendWelcome(email Email) error { ... }
// Email уже провалидирован при создании через NewEmail.
// ПЛОХО: email - просто string
function sendWelcome(string $email): void { /* ... */ }
// Кто гарантирует, что email валиден? Никто.

// ХОРОШО: email - Value Object
function sendWelcome(Email $email): void { /* ... */ }
// Email уже провалидирован при создании через Email::fromString().

2. Мутабельный Value Object

// ПЛОХО: VO с сеттером - нарушение иммутабельности
func (e *Email) SetValue(v string) { e.value = v }

// ХОРОШО: нужен новый email - создай новый VO
newEmail, err := NewEmail("new@example.com")
<?php
declare(strict_types=1);

// ПЛОХО: VO без readonly + сеттер - нарушение иммутабельности
final class Email
{
    private string $value;
    public function setValue(string $v): void { $this->value = $v; }
}

// ХОРОШО: final readonly + фабрика. Нужен новый email - создай новый VO.
$newEmail = Email::fromString('new@example.com');

3. Entity без поведения (Anemic Model)

// ПЛОХО: Entity - просто контейнер данных
type Course struct {
    ID    string
    Title string
    Slug  string
}
// Вся логика - в сервисе. Course ничего не умеет.

// ХОРОШО: Entity содержит бизнес-логику
type Course struct { ... }
func (c *Course) AddLesson() { ... }
func (c *Course) CanPublish() bool { ... }
func (c *Course) Rename(title string) error { ... }
// ПЛОХО: Entity - просто контейнер данных
final class Course
{
    public string $id;
    public string $title;
    public string $slug;
}
// Вся логика - в сервисе. Course ничего не умеет.

// ХОРОШО: Entity содержит бизнес-логику
final class Course
{
    // ...
    public function addLesson(): void { /* ... */ }
    public function canPublish(): bool { /* ... */ }
    public function rename(string $title): void { /* ... */ }
}

Мини-задание

  • Выбери 2 Value Object в своём проекте (Email, Money, Slug, DateRange, PhoneNumber) и реализуй их с приватными полями и конструктором
  • Убедись, что VO неизменяемый - все методы на value receiver, операции возвращают новый VO
  • Напиши тест: два VO с одинаковыми значениями должны быть равны через Equals
  • Найди в коде место с «примитивной одержимостью» (string вместо типа) и замени на VO
  • Проверь свои Entity: есть ли у них поведение или это просто контейнеры данных?

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.