Опирается на правила:
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-2 | pgx только в adapters/out/persistence/ |
Импорт github.com/go-chi/chi в core/<bc>/ | R-MOD-2 | chi только в adapters/in/http/ |
Ссылка на чужой агрегат объектом (*customer.Customer в Order) | R-AGG-5 + R-MOD-1 | VO-обёртка над 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/организованы на уровне модулей.