Опирается на правила:
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-X1 | Pure-domain IsSatisfiedBy + отдельный FindBy… в QueryRepository |
Specification принимается как параметр port.OrderRepository | R-SPEC-X1 | Явный метод порта с параметрами фильтра (FindEligibleForRefund(after time.Time)) |
Specification для одного if в одном Handler | R-SPEC-X2 | Метод на агрегате (order.Cancel()) |
core/specification/ импортирует adapters/out/persistence/sqlcgen | R-MOD-2 + R-SPEC-X1 | Импорт строго в одну сторону: adapters знает про core, не наоборот |
Имя Helper, Checker, Rule, Validator вместо …Spec | R-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 для фильтрации.