Опирается на правила: R-SPEC-1, R-SPEC-2, R-SPEC-X1, R-SPEC-X2 из контракта DDD Tactical → раздел 8. Specification.

Важно знать

  • Specification — struct с методом IsSatisfiedBy(candidate T) bool. В Go нет наследования: интерфейс Specification[T] плюс обёртки-комбинаторы AndSpec, OrSpec, NotSpec.
  • Вводим только когда правило применяется в двух и более местах или нужна явная комбинация через And/Or/Not. Для одного if в одном месте — метод на агрегате.
  • Specification работает в памяти: принимает уже загруженный агрегат или VO, возвращает bool. Не строит SQL-предикатов.
  • R-SPEC-X1 — передавать Specification в репозиторий как WHERE-строитель запрещено: домен тянет зависимость на sqlc/pgx, граница core/adapters/ нарушается.
  • R-SPEC-X2 — Specification ради одного if — преждевременная абстракция. Сначала метод на агрегате, Specification — когда правило начинает кочевать.
  • Имя — утверждение: EligibleForRefund, VipCustomer, ProductInStock. Суффикс Spec по умолчанию.
  • Ошибки-значения в Go: если правило не просто bool, а несёт причину отказа — возвращаем (bool, error) или отдельный тип-результат; не паникуем.

Базовый контракт (R-SPEC-1)

В Go базовые типы из ddd-building-blocks нет — реализуем сами. Интерфейс плюс generic-обёртки:

// core/shared/specification.go
package shared

type Specification[T any] interface {
    IsSatisfiedBy(candidate T) bool
}

type AndSpec[T any] struct {
    left, right Specification[T]
}

func (s AndSpec[T]) IsSatisfiedBy(c T) bool {
    return s.left.IsSatisfiedBy(c) && s.right.IsSatisfiedBy(c)
}

type OrSpec[T any] struct {
    left, right Specification[T]
}

func (s OrSpec[T]) IsSatisfiedBy(c T) bool {
    return s.left.IsSatisfiedBy(c) || s.right.IsSatisfiedBy(c)
}

type NotSpec[T any] struct {
    inner Specification[T]
}

func (s NotSpec[T]) IsSatisfiedBy(c T) bool {
    return !s.inner.IsSatisfiedBy(c)
}

func And[T any](l, r Specification[T]) Specification[T] { return AndSpec[T]{l, r} }
func Or[T any](l, r Specification[T]) Specification[T]  { return OrSpec[T]{l, r} }
func Not[T any](s Specification[T]) Specification[T]    { return NotSpec[T]{s} }

Когда вводим (R-SPEC-2)

Правило «заказ подходит для возврата» применяется в двух местах: при формировании ответа на клиент (показывать ли кнопку «Вернуть») и в IssueRefundHandler при фактической обработке команды. Реализация одна.

// core/order/specification/eligible_for_refund.go
package specification

import (
    "time"

    "example.com/svc/core/order/aggregate"
)

const refundWindow = 14 * 24 * time.Hour

type EligibleForRefundSpec struct {
    now func() time.Time
}

func NewEligibleForRefundSpec(now func() time.Time) EligibleForRefundSpec {
    return EligibleForRefundSpec{now: now}
}

func (s EligibleForRefundSpec) IsSatisfiedBy(order *aggregate.Order) bool {
    if order.Status() != aggregate.OrderStatusDelivered {
        return false
    }
    return s.now().Sub(order.DeliveredAt()) <= refundWindow
}

Два места применения:

// adapters/in/http/order_view_mapper.go
package http

import "example.com/svc/core/order/specification"

type OrderViewMapper struct {
    refundSpec specification.EligibleForRefundSpec
}

func (m OrderViewMapper) ToView(order *aggregate.Order) OrderView {
    return OrderView{
        ID:             order.ID(),
        Total:          order.Total().Amount().String(),
        RefundEligible: m.refundSpec.IsSatisfiedBy(order),
    }
}
// core/order/usecase/issue_refund_handler.go
package usecase

import (
    "context"
    "fmt"

    "example.com/svc/core/order/port"
    "example.com/svc/core/order/specification"
)

type IssueRefundHandler struct {
    orders     port.OrderRepository
    refundSpec specification.EligibleForRefundSpec
}

func (h IssueRefundHandler) Handle(ctx context.Context, cmd IssueRefund) (RefundID, error) {
    order, err := h.orders.ByID(ctx, cmd.OrderID)
    if err != nil {
        return RefundID{}, fmt.Errorf("load order: %w", err)
    }
    if !h.refundSpec.IsSatisfiedBy(order) {
        return RefundID{}, ErrOrderNotEligibleForRefund
    }
    return h.processRefund(ctx, order)
}

Правило одно — реализация одна — mapper и handler не расходятся.

Комбинаторы And/Or/Not (R-SPEC-1)

Сложное правило «клиент получает скидку» складывается из трёх примитивов:

// core/customer/specification/vip_customer.go
package specification

import "example.com/svc/core/customer/aggregate"

type VipCustomerSpec struct{}

func (s VipCustomerSpec) IsSatisfiedBy(c *aggregate.Customer) bool {
    total, _ := c.TotalSpent()
    return total.Amount().GreaterThanOrEqual(threshold100k)
}

type LongtimeCustomerSpec struct {
    now func() time.Time
}

func (s LongtimeCustomerSpec) IsSatisfiedBy(c *aggregate.Customer) bool {
    return s.now().Sub(c.RegisteredAt()) >= 3*365*24*time.Hour
}

type HasActiveSubscriptionSpec struct{}

func (s HasActiveSubscriptionSpec) IsSatisfiedBy(c *aggregate.Customer) bool {
    return c.HasActiveSubscription()
}

Композиция:

// core/customer/specification/premium_discount.go
package specification

import (
    "example.com/svc/core/customer/aggregate"
    "example.com/svc/core/shared"
)

func PremiumDiscountSpec(now func() time.Time) shared.Specification[*aggregate.Customer] {
    vip := VipCustomerSpec{}
    longtime := LongtimeCustomerSpec{now: now}
    subscription := HasActiveSubscriptionSpec{}
    return shared.Or[*aggregate.Customer](vip, shared.And[*aggregate.Customer](longtime, subscription))
}

Применение:

discountSpec := specification.PremiumDiscountSpec(time.Now)
if discountSpec.IsSatisfiedBy(customer) {
    discount = vo.NewDiscountRate(15)
}

Правило читается структурно: «VIP или (давний клиент и активная подписка)». Добавить четвёртое условие — добавить Or(...), не переписывать ветку.

Specification ≠ SQL builder (R-SPEC-X1)

// ЗАПРЕЩЕНО — specification тянет sqlc-артефакт в core/
package specification

import "example.com/svc/adapters/out/persistence/sqlcgen"

type EligibleForRefundSpec struct{}

func (s EligibleForRefundSpec) ToSQL() string {
    return "status = 'DELIVERED' AND delivered_at > NOW() - INTERVAL '14 days'"
}

Что ломается:

  • core/order/specification/ начинает зависеть от adapters/out/persistence/ — нарушается R-MOD-2. depguard сразу покажет.
  • Read-сторона и write-сторона мешаются: OrderRepository.Find начинает принимать Specification как WHERE-строитель, хотя это не его задача.
  • In-memory проверка и SQL-предикат дрейфуют независимо.

Правильное разделение:

// core/order/specification/eligible_for_refund.go — чистый домен, in-memory
func (s EligibleForRefundSpec) IsSatisfiedBy(order *aggregate.Order) bool { ... }

// adapters/out/persistence/order_query_repository.go — read-side, SQL
func (r *pgOrderQueryRepository) FindEligibleForRefund(
    ctx context.Context,
    deliveredAfter time.Time,
) ([]*readmodel.OrderSummary, error) {
    return r.queries.FindEligibleForRefund(ctx, deliveredAfter)
}

Если правило нужно и in-memory и в SQL — держим оба, фиксируем критерий в тесте:

func TestRefundEligibilityConsistency(t *testing.T) {
    spec := specification.NewEligibleForRefundSpec(func() time.Time { return fixedNow })
    order := buildDeliveredOrder(fixedNow.Add(-10 * 24 * time.Hour))
    assert.True(t, spec.IsSatisfiedBy(order))
}

Specification для одного места — не нужна (R-SPEC-X2)

// ИЗБЫТОЧНО — spec для одного if
type OrderCancellableSpec struct{}

func (s OrderCancellableSpec) IsSatisfiedBy(order *aggregate.Order) bool {
    return order.Status() != aggregate.OrderStatusShipped
}

// handler
if !spec.IsSatisfiedBy(order) {
    return ErrOrderNotCancellable
}

Простое правило в одном месте — метод на агрегате:

// core/order/aggregate/order.go
func (o *Order) Cancel() error {
    if o.status == OrderStatusShipped {
        return ErrOrderAlreadyShipped
    }
    o.status = OrderStatusCancelled
    o.registerEvent(event.NewOrderCancelled(uuid.New(), time.Now(), o.ID()))
    return nil
}

Cancel несёт инвариант внутри агрегата. Specification вводим, когда правило начинает переиспользоваться, а не «когда может пригодиться».

Имена — утверждения, не техника

R-SPEC-1 подразумевает: имя — это утверждение, проверяемое через IsSatisfiedBy.

// ПРАВИЛЬНО
EligibleForRefundSpec
VipCustomerSpec
ProductInStockSpec
OrderWithinDeliveryAreaSpec

// НЕПРАВИЛЬНО
OrderValidator       — validator валидирует входные данные команды, не доменное правило
OrderChecker         — неясно, что именно
RefundHelper         — Helper запрещён везде
EligibleRule         — Rule слишком общо

Суффикс Spec однозначно сигнализирует о паттерне. Если проект принял полное Specification — единообразие важнее краткости.

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

АнтипаттернПравилоЧто взамен
Specification.ToSQL() или ToCondition() генерирует SQL-предикатR-SPEC-X1Pure-domain IsSatisfiedBy + отдельный FindBy… в QueryRepository
Specification принимается как параметр port.OrderRepositoryR-SPEC-X1Явный метод порта с параметрами фильтра (FindEligibleForRefund(after time.Time))
Specification для одного if в одном HandlerR-SPEC-X2Метод на агрегате (order.Cancel())
core/specification/ импортирует adapters/out/persistence/sqlcgenR-MOD-2 + R-SPEC-X1Импорт строго в одну сторону: adapters знает про core, не наоборот
Имя Helper, Checker, Rule, Validator вместо …SpecR-SPEC-1Утверждение: EligibleForRefundSpec, VipCustomerSpec

Куда дальше

  • go/aggregate-root.md — простые проверки инвариантов живут на агрегате, а не в Specification.
  • go/domain-service.md — когда правило не сводится к bool, а требует нескольких агрегатов.
  • go/repository.md — read-side: FindEligibleForRefund строится здесь, не через Specification.
  • go/value-object.md — Specification часто принимает VO-параметры (порог, дата).
  • go/domain-event.md — что происходит после того, как IsSatisfiedBy вернул true и агрегат изменил состояние.
  • go/entity.md — Entity внутри агрегата: может ли Specification принимать Entity напрямую.
  • go/factory.md — фабрика может использовать Specification при сборке агрегата.
  • go/module-structure.md — пакет core/<bc>/specification/ и правило R-MOD-2.
  • CQRS Style Guide — query-side и read-model: где строится SQL для фильтрации.