Опирается на правила: R-REP-1R-REP-5 и R-REP-X1R-REP-X3 из DDD Tactical Style Guide → раздел 5. Repository.

Важно знать

  • Порт репозитория — interface в core/<bc>/port/. Домен импортирует только этот interface; реализацию не видит.
  • Реализация — в adapters/out/persistence/ на sqlc-сгенерированных запросах + pgxpool.
  • Save сохраняет агрегат целиком в транзакции. Частичные UpdateStatus / UpdateField — нарушение R-REP-4.
  • После успешного commit репозиторий вызывает order.PullEvents() и публикует события. Для критичных эффектов — Outbox в той же транзакции.
  • Методы порта — в терминах домена: ByID, Save, ActiveByCustomer. Не SelectFromDB, не UpdateStatusInDB.
  • Один репозиторий = один корень агрегата. OrderRepository не возвращает Customer.
  • sqlcgen.Order (row-struct из sqlc) не должен выходить за пределы adapters/out/persistence/.

Repository — абстракция над persistence, которая позволяет домену не знать про SQL. Граница: порт в core/, реализация в adapters/. Раскрытие раздела 5 гайда на Go-стеке.

Порт — interface в core

R-REP-1, R-REP-3, R-REP-5:

// core/order/port/order_repository.go
package port

import (
    "context"

    "github.com/google/uuid"
    "example.com/svc/core/order/aggregate"
)

type OrderRepository interface {
    ByID(ctx context.Context, id uuid.UUID) (*aggregate.Order, error)
    Save(ctx context.Context, order *aggregate.Order) error
    ActiveByCustomer(ctx context.Context, customerID uuid.UUID) ([]*aggregate.Order, error)
}

Методы названы в терминах домена:

  • ByID — найти агрегат по идентификатору.
  • Save — создать или обновить агрегат целиком.
  • ActiveByCustomer — бизнес-запрос, не SelectWhereStatusAndCustomerID.

Домен использует port.OrderRepository через конструкторную инъекцию:

type ConfirmOrderHandler struct {
    orders port.OrderRepository
    clock  func() time.Time
}

Реализация — adapters/out/persistence

R-REP-2: реализация в adapters/out/persistence/. Использует sqlc-сгенерированные запросы и pgxpool:

// adapters/out/persistence/order_repository.go
package persistence

import (
    "context"
    "fmt"

    "github.com/google/uuid"
    "github.com/jackc/pgx/v5/pgxpool"
    "example.com/svc/adapters/out/persistence/sqlcgen"
    "example.com/svc/core/order/aggregate"
    "example.com/svc/core/order/port"
)

type pgOrderRepository struct {
    pool      *pgxpool.Pool
    queries   *sqlcgen.Queries
    publisher DomainEventPublisher
}

func NewOrderRepository(pool *pgxpool.Pool, publisher DomainEventPublisher) port.OrderRepository {
    return &pgOrderRepository{
        pool:      pool,
        queries:   sqlcgen.New(pool),
        publisher: publisher,
    }
}

Конструктор возвращает port.OrderRepository (interface), не *pgOrderRepository. Это гарантирует, что клиенты работают только с портом.

ByID — маппинг из row в агрегат

func (r *pgOrderRepository) ByID(ctx context.Context, id uuid.UUID) (*aggregate.Order, error) {
    row, err := r.queries.GetOrder(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("order by id %s: %w", id, err)
    }
    lines, err := r.queries.GetOrderLines(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("order lines for %s: %w", id, err)
    }
    return toAggregate(row, lines), nil
}

toAggregate — приватная функция маппинга из sqlcgen-типов в агрегат. Возвращает *aggregate.Order. sqlcgen.Order наружу не выходит (R-REP-X1).

Save — целиком в транзакции

R-REP-4: Save сохраняет агрегат целиком. Никаких UPDATE orders SET status = $1:

func (r *pgOrderRepository) Save(ctx context.Context, order *aggregate.Order) error {
    tx, err := r.pool.Begin(ctx)
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer tx.Rollback(ctx)

    q := r.queries.WithTx(tx)
    if err := upsertOrder(ctx, q, order); err != nil {
        return err
    }
    if err := upsertOrderLines(ctx, q, order); err != nil {
        return err
    }
    if err := tx.Commit(ctx); err != nil {
        return fmt.Errorf("commit order %s: %w", order.ID(), err)
    }

    for _, ev := range order.PullEvents() {
        r.publisher.Publish(ctx, ev)
    }
    return nil
}

upsertOrder использует INSERT … ON CONFLICT DO UPDATE — один вызов для create и update. Детали SQL скрыты внутри adapters/, домен не знает про них.

Маппинг domain ↔ sqlcgen

func upsertOrder(ctx context.Context, q *sqlcgen.Queries, o *aggregate.Order) error {
    return q.UpsertOrder(ctx, sqlcgen.UpsertOrderParams{
        ID:         o.ID(),
        CustomerID: o.CustomerID().Value(),
        Status:     int32(o.Status()),
    })
}

func toAggregate(row sqlcgen.Order, lines []sqlcgen.OrderLine) *aggregate.Order {
    // восстанавливает агрегат из row-структур sqlcgen
    // детали зависят от конструктора восстановления на агрегате
    return aggregate.Restore(
        row.ID,
        vo.NewCustomerID(row.CustomerID),
        aggregate.OrderStatus(row.Status),
        toEntityLines(lines),
    )
}

Агрегат имеет два конструктора: NewOrder (создание с событием) и Restore (восстановление из persistence без события). Restore — пакетная функция, не экспортируется за пределы aggregate/.

ActiveByCustomer — бизнес-запрос

func (r *pgOrderRepository) ActiveByCustomer(ctx context.Context, customerID uuid.UUID) ([]*aggregate.Order, error) {
    rows, err := r.queries.GetActiveOrdersByCustomer(ctx, customerID)
    if err != nil {
        return nil, fmt.Errorf("active orders for customer %s: %w", customerID, err)
    }
    result := make([]*aggregate.Order, 0, len(rows))
    for _, row := range rows {
        lines, _ := r.queries.GetOrderLines(ctx, row.ID)
        result = append(result, toAggregate(row, lines))
    }
    return result, nil
}

Для read-only сценариев с проекциями (не полный агрегат) используется отдельный ViewRepositoryport.OrderViewRepository с методами вроде SummaryByCustomer. Это CQRS read-side, не write-side Repository.

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

АнтипаттернПравилоЧто взамен
Возвращать sqlcgen.Order из методов портаR-REP-X1Только доменные типы из core/
Метод UpdateStatusInDB(id, status)R-REP-X2Save(order) — агрегат целиком
Specification в порте для генерации SQLR-REP-X3ViewRepository с конкретными методами на read-side
OrderRepository возвращает []*CustomerR-REP-3Один Repository = один агрегат
Реализация репозитория в core/R-REP-2Только interface в core/; реализация в adapters/out/persistence/

Куда дальше

  • DDD Tactical → раздел 5. Repository — нормативные формулировки R-REP-*.
  • go/aggregate-root.md — PullEvents и структура агрегата.
  • go/domain-event.md — публикация событий и Outbox.
  • go/module-structure.md — куда кладётся port/ и adapters/out/persistence/.
  • CQRS Style GuideViewRepository и read-side запросы.