Опирается на правила: R-ENT-1R-ENT-5 и R-ENT-X1R-ENT-X5 из DDD Tactical Style Guide → раздел 1. Entity.

Важно знать

  • Go не имеет наследования — Entity реализуется через embedding EntityBase[ID] из core/shared/building_blocks.go.
  • EntityBase[ID] хранит id закрытым, экспонирует через ID() и Equals(). Поле id снаружи недоступно.
  • Конструктор New… возвращает (*Entity, error) — возврат ошибки при невалидных данных, не паника.
  • Equality — только через EntityBase.Equals(other.EntityBase), не через == по struct. == на struct в Go сравнивает все поля — это VO-семантика.
  • Состояние меняется бизнес-методами. Публичных полей нет; экспорт данных — через геттеры.
  • Ссылки на другой агрегат — только по VO-обёртке над ID (vo.CustomerID), не *Customer.
  • reflect.DeepEqual на Entity — запрещён: сравнивает все поля, игнорирует identity-семантику.

Entity — объект с устойчивой идентичностью. Два OrderLine равны, если у них одинаковый id, даже если qty или price разные. Это противоположность VO, где равенство по значению. Раскрытие раздела 1 гайда на Go-стеке.

Embedding EntityBase[ID]

R-ENT-1: Entity встраивает shared.EntityBase[ID]. Там хранятся механизмы идентичности и equality:

// core/shared/building_blocks.go
package shared

type EntityBase[ID comparable] struct {
    id ID
}

func NewEntityBase[ID comparable](id ID) EntityBase[ID] {
    return EntityBase[ID]{id: id}
}

func (e EntityBase[ID]) ID() ID { return e.id }

func (e EntityBase[ID]) Equals(other EntityBase[ID]) bool {
    return e.id == other.id
}

Entity в домене заказа:

// core/order/entity/order_line.go
package entity

import (
    "fmt"

    "github.com/google/uuid"
    "example.com/svc/core/order/vo"
    "example.com/svc/core/shared"
)

type OrderLine struct {
    shared.EntityBase[uuid.UUID]
    productID vo.ProductID
    qty       int
    price     vo.Money
}

func NewOrderLine(id uuid.UUID, productID vo.ProductID, qty int, price vo.Money) (*OrderLine, error) {
    if qty <= 0 {
        return nil, fmt.Errorf("qty must be positive, got %d", qty)
    }
    return &OrderLine{
        EntityBase: shared.NewEntityBase[uuid.UUID](id),
        productID:  productID,
        qty:        qty,
        price:      price,
    }, nil
}

func (l *OrderLine) Subtotal() vo.Money {
    return l.price.Multiply(l.qty)
}

func (l *OrderLine) ProductID() vo.ProductID { return l.productID }
func (l *OrderLine) Qty() int                { return l.qty }

NewOrderLine возвращает (*OrderLine, error) — невалидная Entity не существует. id задаётся через NewEntityBase и не меняется.

Equality через Equals, не ==

R-ENT-4: для Entity правильный способ сравнения — Equals() по встроенному EntityBase:

func linesEqual(a, b *OrderLine) bool {
    return a.EntityBase.Equals(b.EntityBase)
}

Почему == неверен:

a, _ := NewOrderLine(someID, pid, 2, price)
b, _ := NewOrderLine(someID, pid, 3, price)

a == b         // false — разные указатели
*a == *b       // false — разные qty, Go сравнивает все поля struct
a.Equals(b.EntityBase)  // true — одинаковый ID

Два объекта с одинаковым id — одна и та же бизнес-сущность, независимо от текущего состояния полей. Это суть identity-based equality.

Конструктор валидирует инварианты

R-ENT-5: невалидная Entity не должна существовать. Конструктор проверяет всё необходимое:

// core/customer/entity/customer.go
package entity

import (
    "fmt"
    "strings"
    "time"

    "github.com/google/uuid"
    "example.com/svc/core/customer/vo"
    "example.com/svc/core/shared"
)

type Customer struct {
    shared.EntityBase[uuid.UUID]
    email     vo.Email
    name      string
    createdAt time.Time
    active    bool
}

func NewCustomer(id uuid.UUID, email vo.Email, name string, now time.Time) (*Customer, error) {
    if strings.TrimSpace(name) == "" {
        return nil, fmt.Errorf("customer name is required")
    }
    return &Customer{
        EntityBase: shared.NewEntityBase[uuid.UUID](id),
        email:      email,
        name:       strings.TrimSpace(name),
        createdAt:  now,
        active:     true,
    }, nil
}

Что проверяется: обязательные поля, структурные инварианты уровня Entity. Бизнес-правила, требующие другого агрегата («нельзя создать заказ для деактивированного клиента»), — это уровень UseCase или Factory.

Бизнес-методы вместо публичных полей

R-ENT-X3, R-ENT-X5: изменение состояния — через методы с бизнес-смыслом, не через прямой доступ к полям.

func (c *Customer) Deactivate() error {
    if !c.active {
        return nil
    }
    c.active = false
    return nil
}

func (c *Customer) ChangeEmail(email vo.Email) {
    c.email = email
}

func (c *Customer) IsActive() bool  { return c.active }
func (c *Customer) Email() vo.Email { return c.email }
func (c *Customer) Name() string    { return c.name }

Метод Deactivate инкапсулирует правило. Если добавится логика (запись времени деактивации, регистрация события) — она попадает сюда, а не рассыпается по хендлерам.

Ссылки на другой агрегат — только по ID

R-ENT-X4: внутри OrderLine не хранится *Product, только vo.ProductID:

type OrderLine struct {
    shared.EntityBase[uuid.UUID]
    productID vo.ProductID   // VO-обёртка над uuid.UUID
    qty       int
    price     vo.Money
}

Загрузка Product по ID для read-сценария — задача UseCase, не Entity.

Что запрещено

АнтипаттернПравилоЧто взамен
reflect.DeepEqual для сравнения EntityR-ENT-X1 / R-ENT-X2EntityBase.Equals() по id
== по struct-значению (сравнивает все поля)R-ENT-X2a.EntityBase.Equals(b.EntityBase)
Публичные поля или публичные сеттерыR-ENT-X3Приватные поля + бизнес-методы
Хранение *Product или *Customer в EntityR-ENT-X4vo.ProductID, vo.CustomerID
Struct без поведения — только геттеры и поляR-ENT-X5Бизнес-методы на Entity, логика рядом с состоянием

Куда дальше

  • DDD Tactical → раздел 1. Entity — нормативные формулировки R-ENT-*.
  • go/value-object.md — что использовать вместо примитивов внутри Entity.
  • go/aggregate-root.md — Entity, которая является корнем и регистрирует события.
  • go/repository.md — как Entity сохраняется через sqlc + pgx.
  • go/module-structure.md — куда кладётся entity/ относительно агрегата.