Опирается на правила: R-VO-1R-VO-5 и R-VO-X1R-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 в VOR-VO-X1Если есть identity — это Entity, а не VO
float64 для денегR-VO-X2shopspring/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 — почему Moneynumeric(p,s), не float8.