Опирается на правила:
R-ENT-1…R-ENT-5иR-ENT-X1…R-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 для сравнения Entity | R-ENT-X1 / R-ENT-X2 | EntityBase.Equals() по id |
== по struct-значению (сравнивает все поля) | R-ENT-X2 | a.EntityBase.Equals(b.EntityBase) |
| Публичные поля или публичные сеттеры | R-ENT-X3 | Приватные поля + бизнес-методы |
Хранение *Product или *Customer в Entity | R-ENT-X4 | vo.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/относительно агрегата.