Опирается на правила:
R-VO-1…R-VO-5иR-VO-X1…R-VO-X3из DDD Tactical Style Guide → раздел 2. Value Object.
Важно знать
- VO в Go — immutable struct с приватными полями. Нет маркер-интерфейса как в Java, форму задаёт структура.
- Equality через
==работает корректно, если все поля comparable (примитивы,uuid.UUID,string, другие comparable-структуры). Для VO так и должно быть.- Конструктор-фабрика
NewX(…)возвращает(X, error)— невалидный VO не создаётся.- Мутирующие операции возвращают новый экземпляр. Метод
Multiply(n int) Moneyне меняет получателя.- Деньги —
shopspring/decimal, никогдаfloat64. Для систем с известным масштабом допустимint64в минорных единицах (копейки), но тогда это оговаривается явно.- ID-тип агрегата — VO-обёртка над
uuid.UUID(type CustomerID struct{ value uuid.UUID }), не голыйuuid.UUID.- Slice внутри VO нарушает immutability — не экспонируй его напрямую.
Value Object важен тем, что в нём, а не который из них. Money{amount: 100, currency: "RUB"} и другой такой же — одно и то же для бизнеса; взаимозаменяемы. Customer с id=c-42 — конкретный объект, не любой Customer с теми же именем и email. Граница VO / Entity. Раскрытие раздела 2 гайда на Go-стеке.
Базовый VO — Money
R-VO-1, R-VO-2, R-VO-3, R-VO-4, R-VO-5:
// core/order/vo/money.go
package vo
import (
"fmt"
"github.com/shopspring/decimal"
)
type Money struct {
amount decimal.Decimal
currency string
}
func NewMoney(amount decimal.Decimal, currency string) (Money, error) {
if amount.IsNegative() {
return Money{}, fmt.Errorf("amount must be non-negative, got %s", amount)
}
if len(currency) != 3 {
return Money{}, fmt.Errorf("currency must be ISO-4217 three-letter code, got %q", currency)
}
return Money{amount: amount, currency: currency}, nil
}
func (m Money) Amount() decimal.Decimal { return m.amount }
func (m Money) Currency() string { return m.currency }
func (m Money) Multiply(factor int) Money {
return Money{
amount: m.amount.Mul(decimal.NewFromInt(int64(factor))),
currency: m.currency,
}
}
func (m Money) Add(other Money) (Money, error) {
if m.currency != other.currency {
return Money{}, fmt.Errorf("currency mismatch: %s vs %s", m.currency, other.currency)
}
return Money{amount: m.amount.Add(other.amount), currency: m.currency}, nil
}
Multiply и Add не меняют получателя — возвращают новый Money. Это R-VO-5. == на Money работает корректно, поскольку decimal.Decimal comparable.
ID-типы агрегатов
R-VO-X2: голый uuid.UUID как идентификатор — primitive obsession. Легко перепутать customerID и orderID в сигнатуре функции, компилятор не заметит:
// ПЛОХО
func (r *pgOrderRepository) ByID(ctx context.Context, id uuid.UUID) (*aggregate.Order, error)
// ХОРОШО — отдельный тип per-агрегат
type OrderID struct{ value uuid.UUID }
func NewOrderID(v uuid.UUID) OrderID { return OrderID{value: v} }
func (o OrderID) Value() uuid.UUID { return o.value }
Аналогично:
// core/customer/vo/customer_id.go
package vo
import "github.com/google/uuid"
type CustomerID struct{ value uuid.UUID }
func NewCustomerID(v uuid.UUID) CustomerID { return CustomerID{value: v} }
func (c CustomerID) Value() uuid.UUID { return c.value }
Теперь сигнатура ByID(ctx, vo.OrderID) не принимает случайный vo.CustomerID — несовместимые типы.
VO с нормализацией — Email
Когда значение требует нормализации при создании, конструктор делает её сразу:
// core/customer/vo/email.go
package vo
import (
"fmt"
"regexp"
"strings"
)
var emailRe = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
type Email struct {
value string
}
func NewEmail(raw string) (Email, error) {
normalized := strings.ToLower(strings.TrimSpace(raw))
if !emailRe.MatchString(normalized) {
return Email{}, fmt.Errorf("invalid email: %q", raw)
}
return Email{value: normalized}, nil
}
func (e Email) Value() string { return e.value }
Email хранит нормализованную строку, equality через == даёт корректный результат.
Мутации возвращают новый экземпляр
R-VO-5: любой метод, «изменяющий» VO, создаёт новый экземпляр:
func (m Money) WithDiscount(pct int) (Money, error) {
if pct < 0 || pct > 100 {
return Money{}, fmt.Errorf("discount percent must be 0..100, got %d", pct)
}
factor := decimal.NewFromInt(int64(100 - pct)).Div(decimal.NewFromInt(100))
return Money{amount: m.amount.Mul(factor), currency: m.currency}, nil
}
Использование:
price, _ := vo.NewMoney(decimal.NewFromInt(1000), "RUB")
discounted, err := price.WithDiscount(15)
// price не изменился — discounted новый экземпляр
Composite VO
VO может содержать другие VO:
// core/customer/vo/address.go
package vo
type Address struct {
city string
street string
zip string
}
func NewAddress(city, street, zip string) (Address, error) {
if city == "" || street == "" || zip == "" {
return Address{}, fmt.Errorf("city, street and zip are required")
}
return Address{city: city, street: street, zip: zip}, nil
}
func (a Address) City() string { return a.city }
func (a Address) Street() string { return a.street }
func (a Address) Zip() string { return a.zip }
Address не содержит slice — все поля comparable, == работает.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Публичные поля в VO (type Money struct { Amount decimal.Decimal }) | R-VO-2 | Приватные поля + геттеры |
ID или поле createdAt в VO | R-VO-X1 | Если есть identity — это Entity, а не VO |
float64 для денег | R-VO-X2 | shopspring/decimal или int64 в копейках |
uuid.UUID напрямую как ID агрегата | R-VO-X2 | Типизированный type OrderID struct{ value uuid.UUID } |
| Slice внутри VO, возвращаемый по ссылке | R-VO-X3 | Не экспонировать slice напрямую; возвращать копию |
Куда дальше
- DDD Tactical → раздел 2. Value Object — нормативные формулировки
R-VO-*. - go/entity.md — Entity с identity-based equality в отличие от VO.
- go/aggregate-root.md — ссылки между агрегатами через VO-ID.
- PostgreSQL types — почему
Money↔numeric(p,s), неfloat8.