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: объект с идентичностью
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;
}
}
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);
}
}
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: сравнение
| Критерий | Entity | Value Object |
|---|---|---|
| Идентичность | По ID (UUID, int) | По значениям полей |
| Изменяемость | Мутируется (поля меняются) | Неизменяемый (immutable) |
| Жизненный цикл | Создание -> изменение -> удаление | Создание -> использование |
| Сравнение | a.ID() == b.ID() | a.Equals(b) по всем полям |
| Примеры | User, Course, Order | Email, 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
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: есть ли у них поведение или это просто контейнеры данных?