Опирается на правила: R-MOD-1, R-MOD-2 из контракта DDD Tactical → раздел 9. Module (структура пакетов).

Важно знать

  • Верхний уровень в core/Bounded Context (core/order/, core/customer/), не тип объекта.
  • Внутри BC — подпакеты по роли: aggregate/, entity/, vo/, event/, port/, service/, specification/, usecase/.
  • core/shared/building_blocks.go — единственное место для EntityBase[ID], AggregateBase[ID], интерфейса DomainEvent; любой BC импортирует core/shared, но не соседний BC целиком.
  • adapters/ — строго отдельно: adapters/in/http/ (chi-роутер), adapters/out/persistence/ (sqlc + pgx), adapters/out/messaging/.
  • core/<bc>/ не импортирует adapters/*, github.com/go-chi/chi, github.com/jackc/pgx, github.com/sqlc-dev/sqlc — проверяется depguard или go-arch-lint в CI.
  • Ссылки между агрегатами — только по VO-обёртке над uuid.UUID (vo.CustomerID, не *customer.Customer).
  • Группировка по типу (core/entity/, core/service/, core/repository/) — нарушение R-MOD-1: при росте сервиса разобраться, «всё про Order», невозможно без прыжков между пятью папками.

Структура пакетов — это архитектурное решение, а не вопрос стиля. В Go, где нет пространств имён и visibility управляется регистром имени, плохая раскладка по пакетам быстро превращает правила видимости в мёртвую зону: entity.Order становится доступен из repository.CustomerRepository без каких-либо механизмов запрета. Группировка по домену исправляет это структурно.

Канонический layout

R-MOD-1: верхний уровень core/ — по Bounded Context, не по типу.

core/
  shared/
    building_blocks.go            // EntityBase[ID], AggregateBase[ID], DomainEvent
  order/                          // Bounded Context: Order
    aggregate/
      order.go                    // тип Order, встраивает AggregateBase[uuid.UUID]
      order_factory.go            // CreateOrder(params) — только при сложной сборке
    entity/
      order_line.go               // OrderLine, встраивает EntityBase[uuid.UUID]
    vo/
      money.go                    // Money{amount decimal.Decimal, currency string}
      order_id.go
      order_status.go
      customer_id.go              // ссылка на другой BC — только по VO с uuid
    event/
      order_created.go            // OrderCreated — прошедшее время, приватные поля
      order_confirmed.go
      order_cancelled.go
    port/
      order_repository.go         // interface OrderRepository { ByID / Save / ... }
      event_publisher.go          // interface DomainEventPublisher { Publish }
    service/                      // опционально: только если ≥ 2 агрегатов
    specification/                // опционально: только если правило в ≥ 2 местах
    usecase/
      command/
        create_order.go           // type CreateOrderCmd struct { CustomerID vo.CustomerID ... }
        create_order_handler.go   // func (h *CreateOrderHandler) Handle(ctx, cmd) error
        confirm_order.go
        confirm_order_handler.go
      query/
        get_order.go
        get_order_handler.go
  customer/                       // Bounded Context: Customer
    aggregate/
    vo/
    port/
    usecase/
  product/                        // Bounded Context: Product
    aggregate/
    vo/
    port/
    usecase/

adapters/
  in/
    http/
      order_handler.go            // chi-роутер, request/response DTO
      order_mapper.go             // HTTP DTO ↔ команда/запрос
  out/
    persistence/
      sqlcgen/                    // сгенерированный sqlc-код (не трогаем вручную)
      order_repository.go         // implements port.OrderRepository через pgx + sqlcgen
      order_mapper.go             // sqlcgen.Order → aggregate.Order и обратно
    messaging/
      kafka_event_publisher.go    // implements port.DomainEventPublisher

app/
  wire.go                         // ручная конструкторная сборка или google/wire

Что даёт такой layout:

  • «Всё про Order» — одна папка. Открыл core/order/, видишь агрегат, события, порт репозитория, команды. Не нужно скакать между core/entity/, core/service/, core/repository/.
  • Перенос BC в отдельный сервис — одна операция. core/order/ плюс соответствующий адаптер переезжают целиком. Ссылки на соседние BC уже через VO по uuid — не объекты.
  • Линтер enforce. go-arch-lint или depguard запрещает core/**adapters/**; правило проверяется в CI, а не на ревью.

Зависимости между слоями

Стрелка зависимостей строго однонаправлена:

app/ → adapters/ → core/shared/
                ↘
               core/<bc>/

Конкретно в Go-импортах:

// adapters/out/persistence/order_repository.go — МОЖНО
import (
    "example.com/svc/core/order/aggregate"   // доменный тип
    "example.com/svc/core/order/port"        // интерфейс, который реализуем
    "example.com/svc/core/shared"            // building blocks
    "example.com/svc/adapters/out/persistence/sqlcgen"  // sqlc-ген
    "github.com/jackc/pgx/v5/pgxpool"       // pgx — инфраструктура, в adapters/ ок
)

// core/order/aggregate/order.go — НЕЛЬЗЯ
import (
    "github.com/jackc/pgx/v5"           // R-MOD-2: pgx в domain
    "github.com/go-chi/chi/v5"          // R-MOD-2: chi в domain
    "example.com/svc/adapters/..."      // R-MOD-2: adapters в core
)

R-MOD-2: домен не знает про инфраструктуру. Enforcement:

# .go-arch-lint.yml
rules:
  - from: "core/**"
    to: "adapters/**"
    allow: false
  - from: "core/**"
    to:
      - "github.com/jackc/pgx/**"
      - "github.com/go-chi/chi/**"
      - "github.com/sqlc-dev/sqlc/**"
    allow: false

Без такого файла граница размывается постепенно — «просто импортнём pgxpool здесь, один раз».

Что живёт в core/shared/

core/shared/building_blocks.go — основание всего DDD-стека сервиса. Содержит только базовые типы без бизнес-логики:

// core/shared/building_blocks.go
package shared

import (
    "time"

    "github.com/google/uuid"
)

type EntityBase[ID comparable] struct {
    id ID
}

func NewEntityBase[ID comparable](id ID) EntityBase[ID] {
    return EntityBase[ID]{id: id}
}

func (e EntityBase[ID]) ID() ID { return e.id }

func (e EntityBase[ID]) Equals(other EntityBase[ID]) bool {
    return e.id == other.id
}

type DomainEvent interface {
    EventID() uuid.UUID
    OccurredAt() time.Time
    AggregateID() uuid.UUID
}

type AggregateBase[ID comparable] struct {
    EntityBase[ID]
    events []DomainEvent
}

func NewAggregateBase[ID comparable](id ID) AggregateBase[ID] {
    return AggregateBase[ID]{EntityBase: NewEntityBase[ID](id)}
}

func (a *AggregateBase[ID]) registerEvent(ev DomainEvent) {
    a.events = append(a.events, ev)
}

func (a *AggregateBase[ID]) PullEvents() []DomainEvent {
    evs := make([]DomainEvent, len(a.events))
    copy(evs, a.events)
    a.events = a.events[:0]
    return evs
}

registerEvent намеренно с маленькой буквы — package-private. Только агрегат в том же пакете (а shared не является доменным пакетом — агрегаты встраивают AggregateBase, но регистрация событий идёт через встроенный unexported метод). Каждый агрегат получает доступ к registerEvent через embedding: o.registerEvent(...) вызывается внутри core/order/aggregate/order.go — и это единственный способ добавить событие.

Группировка по типу — антипаттерн

R-MOD-1 запрещает:

// ПЛОХО — package-by-layer
core/
  entity/
    order.go
    customer.go
    product.go
  repository/
    order_repository.go
    customer_repository.go
  service/
    order_service.go
    pricing_service.go

Три проблемы:

  • Видимость не защищает. entity.Customer (exported) доступен из service/order_service.go напрямую — нарушение R-AGG-5 (ссылка на чужой агрегат объектом) не видно из структуры.
  • Рост = хаос. При 50+ файлах entity/ содержит агрегаты, internal Entity, VO — три разных рода объектов в одной папке.
  • Перенос BC = пересборка. Вынести Order в отдельный сервис — значит собирать по одному файлу из entity/, repository/, service/. Никакой атомарности.

Для сравнения — разбор «всё про Order» при группировке по домену:

core/order/           ← зашёл сюда
  aggregate/order.go  ← агрегат
  vo/money.go         ← value objects
  event/              ← события
  port/               ← порты
  usecase/command/    ← команды и их обработчики

Один вход, полная картина.

usecase/ — прикладной слой рядом с доменом

usecase/ — Application Service уровень. Он оркестрирует: загружает агрегат из репозитория, вызывает методы, сохраняет, публикует события. Бизнес-правила живут в aggregate/, оркестрация — в usecase/.

// core/order/usecase/command/confirm_order_handler.go
package command

import (
    "context"
    "fmt"
    "time"

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

type ConfirmOrderCmd struct {
    OrderID string
}

type ConfirmOrderHandler struct {
    repo      port.OrderRepository
    publisher port.DomainEventPublisher
}

func NewConfirmOrderHandler(repo port.OrderRepository, pub port.DomainEventPublisher) *ConfirmOrderHandler {
    return &ConfirmOrderHandler{repo: repo, publisher: pub}
}

func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd ConfirmOrderCmd) error {
    order, err := h.repo.ByID(ctx, mustParseUUID(cmd.OrderID))
    if err != nil {
        return fmt.Errorf("load order: %w", err)
    }

    if err := order.Confirm(time.Now); err != nil {
        return err
    }

    if err := h.repo.Save(ctx, order); err != nil {
        return fmt.Errorf("save order: %w", err)
    }

    for _, ev := range order.PullEvents() {
        if err := h.publisher.Publish(ctx, ev); err != nil {
            return fmt.Errorf("publish event: %w", err)
        }
    }
    return nil
}

Handler получает зависимости через конструктор (port.OrderRepository, port.DomainEventPublisher) — оба интерфейса из core/order/port/, не из adapters/. Сборка зависимостей — в app/wire.go.

Ссылки между Bounded Context

Между BC передаётся только идентификатор, обёрнутый в VO:

// core/order/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 }

// core/order/aggregate/order.go
type Order struct {
    shared.AggregateBase[uuid.UUID]
    customerID vo.CustomerID      // VO, не *customer.Customer
    ...
}

core/order/ не импортирует core/customer/ — нарушение будет поймано go-arch-lint ещё до ревью.

Пример: Product и Sber Payment

Два BC с разными внешними зависимостями, оба изолированы в core/:

core/
  product/
    aggregate/product.go          // Product встраивает AggregateBase[uuid.UUID]
    vo/price.go                   // Price{amount decimal.Decimal, currency string}
    vo/sku.go                     // SKU{value string}
    event/product_activated.go
    port/product_repository.go
    usecase/command/
      activate_product.go
      activate_product_handler.go

adapters/
  out/
    persistence/
      product_repository.go       // implements core/product/port.ProductRepository
    sber/
      sber_payment_client.go      // implements core/payment/port.PaymentGateway
                                  // — внешняя интеграция в adapters/out/, не в core/

Клиент Sber SDK (sber-sdk-go) импортируется только в adapters/out/sber/. Пакет core/payment/ видит лишь интерфейс PaymentGateway из core/payment/port/ — без ссылок на внешний SDK.

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

АнтипаттернПравилоЧто взамен
Верхнеуровневые core/entity/, core/service/, core/repository/R-MOD-1Группировка по BC: core/order/, core/customer/
Импорт github.com/jackc/pgx в core/<bc>/R-MOD-2pgx только в adapters/out/persistence/
Импорт github.com/go-chi/chi в core/<bc>/R-MOD-2chi только в adapters/in/http/
Ссылка на чужой агрегат объектом (*customer.Customer в Order)R-AGG-5 + R-MOD-1VO-обёртка над uuid: vo.CustomerID
Импорт core/order/ из core/customer/R-MOD-1Общие типы — в core/shared/; связь через ID или события
sqlcgen-типы в сигнатурах port/R-REP-X1 + R-MOD-2Маппер sqlcgen.Row → aggregate.Order в adapters/out/persistence/

Куда дальше

  • go/aggregate-root.md — AggregateBase[ID], registerEvent, PullEvents внутри агрегата.
  • go/entity.md — EntityBase[ID], identity-equality, конструктор с валидацией.
  • go/value-object.md — immutable struct, ==-equality, shopspring/decimal.
  • go/domain-event.md — интерфейс DomainEvent, приватные поля, имя в прошедшем времени.
  • go/repository.md — port.OrderRepository, реализация через sqlc + pgx, Save с транзакцией.
  • go/domain-service.md — когда вводить service/, stateless struct.
  • go/factory.md — CreateOrderParams, когда конструктор не справляется.
  • go/specification.md — IsSatisfiedBy, комбинаторы, когда вводить.
  • Смежный раздел — Hexagonal Architecture — как core/, adapters/ и app/ организованы на уровне модулей.