Перейти к содержимому

DDD-основы и Dependency Injection в Go

Зачем знать: DDD даёт язык и инструменты для моделирования сложного домена, без которых код через год превращается в кашу. DI определяет, как зависимости попадают в код — от ручной сборки в main до runtime-фреймворков (fx). На middle-уровне ждут понимания, КОГДА это применять, а когда — нет.

  1. Базовая концепция
  2. В Go идиоматично
  3. Gotchas
  4. Best practices
  5. Вопросы на собесе
  6. Practice
  7. Источники

Domain-Driven Design (Eric Evans, 2003) — подход к проектированию ПО, где домен (бизнес-область) — центр модели. Делится на:

  • Strategic DDD: bounded context, context map, ubiquitous language.
  • Tactical DDD: entity, value object, aggregate, repository, domain service, domain event.

Middle 1 обычно встречается с tactical DDD; strategic — забота архитекторов.

Объект с identity. Две сущности равны, если равны их ID (а не поля).

type User struct {
ID uuid.UUID
Name string
Email string
}
u1 := User{ID: id, Name: "Alice"}
u2 := User{ID: id, Name: "Bob"}
// u1 == u2 если сравнивать по ID — это одна и та же сущность

Entity имеет жизненный цикл (создаётся, меняется, удаляется) и поведение (методы, выражающие бизнес-правила).

Immutable, equality by value, без identity.

type Money struct {
Amount int64
Currency string
}
// Equality: m1 == m2 если совпадают все поля
m1 := Money{Amount: 100, Currency: "USD"}
m2 := Money{Amount: 100, Currency: "USD"}
// m1 == m2
// Mutability: операции возвращают НОВОЕ значение
func (m Money) Add(o Money) Money {
return Money{Amount: m.Amount + o.Amount, Currency: m.Currency}
}

Примеры VO: Money, Address, Email, DateRange, Coordinates.

В Go VO часто реализуется как struct без указателей, чтобы Go копировал по значению.

Aggregate — кластер entities и VO, образующих границу транзакционной консистентности. Aggregate Root — единственный entity, через который внешний код взаимодействует с aggregate.

Order (Aggregate Root)
├── OrderItem[] (entities внутри aggregate)
├── ShippingAddr (VO)
└── Total (VO)

Правила:

  1. Извне доступен только Aggregate Root.
  2. Изменения внутри aggregate — атомарны (одна транзакция).
  3. Между aggregates — только ссылки по ID, не по объекту.
  4. Один Repository — на один Aggregate Root.
// ХОРОШО: меняем item только через root
order.ChangeItemQuantity(itemID, 3)
// ПЛОХО: достали item напрямую
item := order.Items[0]
item.Quantity = 3 // обход инвариантов

Абстракция доступа к persistence для Aggregate Root.

type OrderRepository interface {
Save(ctx context.Context, o *Order) error
GetByID(ctx context.Context, id uuid.UUID) (*Order, error)
}

Один Repository на одного Aggregate Root. Нет OrderItemRepository — доступ к item только через Order.

Поведение, не принадлежащее ни одной entity. Чисто доменное (без БД, HTTP).

// Pricer — не часть Order, не часть Product. Это domain service.
type Pricer struct {
discounts DiscountPolicy
}
func (p *Pricer) Calculate(order *Order, user *User) Money {
base := order.Total
if user.IsPremium() {
base = p.discounts.Apply(base, "premium")
}
return base
}

Факт, произошедший в домене (OrderCreated, PaymentReceived). Используется для интеграции и saga.

type OrderCreated struct {
OrderID uuid.UUID
UserID uuid.UUID
Total Money
OccuredAt time.Time
}

Entity накапливает события, use case публикует:

type Order struct {
// ...
events []any
}
func (o *Order) Cancel() error {
if o.Status == StatusShipped { return ErrCannotCancel }
o.Status = StatusCanceled
o.events = append(o.events, OrderCanceled{OrderID: o.ID, At: time.Now()})
return nil
}
func (o *Order) PullEvents() []any {
e := o.events
o.events = nil
return e
}

Единый язык между разработчиками, продактами и бизнесом. Если бизнес говорит «оформление заказа» — в коде не должно быть createPurchase(). Имена в коде = имена в требованиях.

Граница, в которой термины однозначны. Один и тот же Customer в Sales и Billing — разные объекты, даже если у них совпадает ID. Каждый bounded context — обычно отдельный модуль или микросервис.

  • CRUD-сервис без бизнес-правил (админка, lookup-сервис). Анемичные DTO + handlers — нормально.
  • Прототип/MVP. Сначала найдите домен, потом моделируйте.
  • Стартап на стадии product-market fit. Слишком рано — модели меняются каждую неделю.

Dependency Injection — паттерн, где зависимости (объекты, нужные для работы) передаются извне, а не создаются внутри.

// БЕЗ DI: зависимость создаётся внутри
type OrderService struct{}
func (s *OrderService) Create() {
db := pgxpool.New(...) // hardcoded!
db.Exec(...)
}
// С DI: зависимость инжектится в конструкторе
type OrderService struct {
db *pgxpool.Pool
}
func NewOrderService(db *pgxpool.Pool) *OrderService {
return &OrderService{db: db}
}

В Go DI чаще всего — это просто передача в конструктор. Никакой магии.


В большинстве проектов достаточно ручной сборки в main.go. Это идиоматично:

func main() {
// инфраструктура
db := mustOpenDB()
cache := redis.NewClient(...)
kafka := newKafka()
// адаптеры
orderRepo := postgres.NewOrderRepo(db)
userRepo := postgres.NewUserRepo(db)
publisher := kafkaadapter.New(kafka)
// use cases
createOrder := usecase.NewCreateOrderUseCase(orderRepo, userRepo, publisher)
payOrder := usecase.NewPayOrderUseCase(orderRepo, paymentClient)
// handlers
orderHandler := httpapi.NewOrderHandler(createOrder, payOrder)
// server
srv := newServer(orderHandler)
srv.Run()
}

Это и есть DI. В 90% Go-проектов фреймворк не нужен.

Когда у конструктора много опциональных параметров — вместо длинного списка аргументов используют functional options:

type Server struct {
addr string
timeout time.Duration
logger *slog.Logger
tlsConfig *tls.Config
}
type Option func(*Server)
func WithTimeout(t time.Duration) Option {
return func(s *Server) { s.timeout = t }
}
func WithLogger(l *slog.Logger) Option {
return func(s *Server) { s.logger = l }
}
func WithTLS(c *tls.Config) Option {
return func(s *Server) { s.tlsConfig = c }
}
func NewServer(addr string, opts ...Option) *Server {
s := &Server{
addr: addr,
timeout: 30 * time.Second, // sensible defaults
logger: slog.Default(),
}
for _, opt := range opts {
opt(s)
}
return s
}
// Использование
srv := NewServer(":8080",
WithTimeout(10 * time.Second),
WithLogger(myLogger),
WithTLS(tlsCfg),
)

Преимущества:

  • API расширяется без breaking changes (добавляешь WithX, не ломая старый код).
  • Defaults задаются в одном месте.
  • Не приходится передавать nil/zero для неиспользуемых полей.

Идиома пришла из gRPC, net/http, pgx.

wire — генератор кода, который собирает граф зависимостей во время компиляции.

Установка: go install github.com/google/wire/cmd/wire@latest.

// wire.go (с build tag, чтобы не компилировался)
//go:build wireinject
package main
import (
"github.com/google/wire"
)
func InitializeApp() (*App, error) {
wire.Build(
NewPostgresPool,
postgres.NewOrderRepo,
wire.Bind(new(usecase.OrderRepository), new(*postgres.OrderRepo)),
usecase.NewCreateOrderUseCase,
httpapi.NewOrderHandler,
NewApp,
)
return nil, nil // wire генерирует реальное тело
}
Окно терминала
$ wire
# создаёт wire_gen.go
// wire_gen.go (generated, без build tag)
package main
func InitializeApp() (*App, error) {
pool, err := NewPostgresPool()
if err != nil { return nil, err }
repo := postgres.NewOrderRepo(pool)
useCase := usecase.NewCreateOrderUseCase(repo)
handler := httpapi.NewOrderHandler(useCase)
app := NewApp(handler)
return app, nil
}

Плюсы wire:

  • Ошибки графа — на этапе компиляции (нет panic at startup).
  • Нет рефлексии, нет runtime-оверхеда.
  • Сгенерированный код — обычный Go, читаемый.

Минусы:

  • Нужно перегенерировать при изменении графа.
  • Provider sets могут быть громоздкими.
  • Не управляет lifecycle (Start/Stop).

fx — runtime-фреймворк, основанный на dig (uber-go/dig).

package main
import (
"context"
"go.uber.org/fx"
)
func main() {
fx.New(
fx.Provide(
NewPostgresPool,
postgres.NewOrderRepo,
// привязка интерфейса
fx.Annotate(
postgres.NewOrderRepo,
fx.As(new(usecase.OrderRepository)),
),
usecase.NewCreateOrderUseCase,
httpapi.NewOrderHandler,
NewHTTPServer,
),
fx.Invoke(StartHTTPServer),
).Run()
}
func NewHTTPServer(lc fx.Lifecycle, h *httpapi.OrderHandler) *http.Server {
srv := &http.Server{Addr: ":8080", Handler: h.Routes()}
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
go srv.ListenAndServe()
return nil
},
OnStop: func(ctx context.Context) error {
return srv.Shutdown(ctx)
},
})
return srv
}

Плюсы fx:

  • Lifecycle management (OnStart/OnStop) — graceful shutdown в фреймворке.
  • Модули (fx.Module) для крупных приложений.
  • Параметры через struct с тегами (group, name).
  • Hot path для тестов: fxtest.New(...).RequireStart().Stop().

Минусы:

  • Ошибки графа — только в runtime (panic при старте, если не хватило provider).
  • Рефлексия → медленнее старт, сложнее отладка.
  • Учебная кривая (теги, модули, hooks).

dig — низкоуровневая основа fx. Без lifecycle, просто граф зависимостей.

c := dig.New()
c.Provide(NewPostgresPool)
c.Provide(postgres.NewOrderRepo)
c.Provide(func(r *postgres.OrderRepo) usecase.OrderRepository { return r })
c.Provide(usecase.NewCreateOrderUseCase)
c.Invoke(func(uc *usecase.CreateOrderUseCase) {
// граф собран, uc — готов
})

Используется редко напрямую — обычно через fx.

ПараметрManualwirefx
Время стартамгновенномгновеннозаметно медленнее
Тип проверкиcompilecompileruntime (panic)
Generated codeнетданет
Lifecycleсамисамивстроенный
Кривая обученияплоскаясредняякрутая
Когда выбиратьдо 30-50 зависимостейсредние/большие проектыбольшие проекты с lifecycle

Рекомендация для middle 1: начать с manual, выучить wire для среднего проекта, fx — когда реально нужен lifecycle (десятки сервисов в монорепе, hot-reload, и т.п.).

Главная польза DI — лёгкая подмена зависимостей в тестах.

func TestCreateOrder(t *testing.T) {
repo := &fakeOrderRepo{}
pub := &fakePublisher{}
uc := usecase.NewCreateOrderUseCase(repo, pub)
// тест use case без БД и Kafka
}

С wire/fx — то же самое: в тестах вы НЕ используете DI-фреймворк, а собираете зависимости вручную, через mock/fake.

  • Конструктор называется New<Type> или New<Type>WithConfig для variant.
  • Возвращает *<Type>, error или просто *<Type>.
  • Принимает зависимости первыми, конфигурацию — последней.
  • Не делает heavy work (BD-коннект, HTTP-вызовы) — это в Start()/Run().
// Стандарт:
func NewOrderRepo(pool *pgxpool.Pool) *OrderRepo
// Variants:
func NewOrderRepoWithLogger(pool *pgxpool.Pool, log *slog.Logger) *OrderRepo
func NewOrderRepoFromEnv() (*OrderRepo, error)

Альтернатива опциям — config struct:

type Config struct {
Addr string
Timeout time.Duration
Logger *slog.Logger
}
func NewServer(cfg Config) *Server { ... }
srv := NewServer(Config{Addr: ":8080", Timeout: 10*time.Second})

Сравнение:

OptionsConfig struct
Defaultsв Newзаранее не понятно
Расширяемостьда, без breaking changesда, но новое поле — zero value
ЧитаемостьWithX(...) явныйполя struct явные
Required vs Optionalсложно различитьсложно различить

Многие либы используют ОБА: required → аргумент, optional → options.

В fx уже встроено. Вручную:

type Lifecycle interface {
Start(ctx context.Context) error
Stop(ctx context.Context) error
}
type App struct {
components []Lifecycle
}
func (a *App) Start(ctx context.Context) error {
for _, c := range a.components {
if err := c.Start(ctx); err != nil {
return err
}
}
return nil
}
func (a *App) Stop(ctx context.Context) error {
// reverse order
for i := len(a.components) - 1; i >= 0; i-- {
a.components[i].Stop(ctx)
}
return nil
}

Самая частая ошибка в Go из-за привычки писать struct+function.

// ПЛОХО: анемия
type Order struct {
Status string
}
func CancelOrder(o *Order) error {
if o.Status == "shipped" { return errors.New("...") }
o.Status = "canceled"
return nil
}
// ХОРОШО: поведение на entity
func (o *Order) Cancel() error { ... }
// ПЛОХО: глобальный container
var Container = di.New()
type OrderService struct{}
func (s *OrderService) Create() {
repo := Container.Get("OrderRepo").(*OrderRepo)
// ...
}

Это нарушает explicit dependencies, скрывает связи, ломает тестирование. В Go — категорически не делать.

// ПЛОХО:
type Repository interface {
SaveOrder(...)
SaveUser(...)
SaveProduct(...)
}

Это либо «бог-репозиторий», либо нарушение aggregate root правил. Каждому aggregate — свой repository.

Если Order содержит User, History, Reviews, Comments, всё в одной транзакции — это монолитный aggregate. Производительность страдает, конкуренция за блокировки растёт. Делите по консистентности: если данные могут быть eventually consistent — отдельный aggregate.

fx.New(
fx.Provide(NewOrderRepo), // зависит от *pgxpool.Pool
// забыли fx.Provide(NewPgPool)
).Run()
// panic: missing dependencies for func ...

Решение: продумывайте граф заранее, тестируйте startup в CI:

func TestApp_Startup(t *testing.T) {
app := fxtest.New(t, AppOptions...).RequireStart()
defer app.RequireStop()
}

После добавления зависимости в wire.Build нужно перегенерировать wire_gen.go. Часто забывают, ловят compile error в неожиданном месте. Решение — go generate ./... в CI и pre-commit hook.

Use case A зависит от use case B, B — от A. Решение:

  • вынести общую логику в domain service;
  • использовать domain event (A публикует событие, B реагирует).
// ПЛОХО:
func NewOrderRepo(dsn string) *OrderRepo {
pool, _ := pgxpool.New(context.Background(), dsn) // блокирует
return &OrderRepo{pool: pool}
}

Конструктор должен быть быстрым и НЕ должен делать сеть/диск. Это делает Start() или явный init:

func NewOrderRepo(pool *pgxpool.Pool) *OrderRepo { ... } // pool создан снаружи

3.9 Использование DI-фреймворка для библиотеки

Заголовок раздела «3.9 Использование DI-фреймворка для библиотеки»

Не используйте wire/fx в публичных Go-библиотеках. Пользователь не должен зависеть от вашего фреймворка. Только в приложениях (main module).

// ПЛОХО:
type Config struct {
DB *pgxpool.Pool
Redis *redis.Client
Logger *slog.Logger
Kafka *kafka.Conn
// ...
}
func NewOrderService(cfg Config) *OrderService { ... }

OrderService нужен только pool, но получил весь Config. Это god object. Передавайте только то, что используете:

func NewOrderService(pool *pgxpool.Pool, log *slog.Logger) *OrderService

В классическом DDD — entity. Но если БД-схема сильно отличается от domain, удобно иметь промежуточный DTO внутри репозитория:

func (r *OrderRepo) GetByID(...) (*domain.Order, error) {
var row orderRow
// SELECT ... INTO row
return row.toDomain(), nil // конвертация в entity
}
Сервис: GET /users/{id} → SELECT * FROM users WHERE id=$1

Если домен такой — DDD не нужен. Делайте transaction script.

DDD НЕ обязан быть event-sourced. Event sourcing — отдельный паттерн, и он подходит только для специфических задач (audit, time-travel). Не путайте domain events (для интеграции) с event sourcing (как persistence).


  1. Entity = данные + поведение. Никаких анемичных моделей.
  2. Aggregate Root — единственный entry point. Не достаём item напрямую, только через root.
  3. Один Repository на один Aggregate Root.
  4. VO immutable, methods возвращают новые значения.
  5. Domain service — когда логика не принадлежит entity.
  6. Ubiquitous language. Имена в коде = имена в требованиях.
  7. Bounded context = модуль/сервис. Не смешивайте Sales::Customer и Billing::Customer.
  8. DI через конструктор. Никакого Service Locator.
  9. Functional options для большого числа опциональных параметров.
  10. Composition root — main.go. Граф собирается в одном месте.
  11. wire — для среднего проекта, fx — для большого с lifecycle.
  12. Manual DI — пока работает. Не тащите фреймворк ради «правильно».
  13. Test без DI-фреймворка. Подменяйте зависимости вручную в тестах.
  14. Конструктор не делает heavy work.
  15. Domain events накапливаются в entity, публикуются use case.

  1. Чем Entity отличается от Value Object? Entity имеет identity (ID), equality by reference. VO immutable, equality by value.

  2. Что такое Aggregate Root и зачем он? Единственный entity, через который доступен aggregate. Гарантирует инварианты, ограничивает зону транзакционной консистентности.

  3. Сколько Repository должно быть в системе с 10 entity? По числу Aggregate Roots, не entity. Если 3 aggregate (Order, User, Catalog) — 3 repository.

  4. Что такое Anemic Domain Model? Entity без поведения, только поля. Логика «размазана» по сервисам. Антипаттерн в DDD/CleanArch.

  5. Что такое Ubiquitous Language? Единый язык домена между разработчиками и бизнесом. Имена в коде = имена в требованиях.

  6. Что такое Bounded Context? Граница, в которой термины однозначны. Один Customer в Sales и Billing — разные сущности. Обычно = микросервис.

  7. Domain Service vs Application Service? Domain Service — чистая бизнес-логика, без БД/HTTP (например, Pricer). Application Service (use case) — оркестратор, вызывает domain + repositories.

  8. Когда DDD не нужен? CRUD-сервис, прототип/MVP, lookup-сервис без правил.

  9. Что такое Dependency Injection? Паттерн, где зависимости передаются извне (через конструктор/setter), а не создаются внутри.

  10. Чем wire отличается от fx? wire — compile-time codegen, проверки на этапе компиляции. fx — runtime DI с reflection и встроенным lifecycle.

  11. Когда нужен DI-фреймворк? Когда граф зависимостей становится сложным (50+ компонентов), нужен lifecycle management или модули. До этого manual DI достаточно.

  12. Что такое functional options? Паттерн: func(*Type) для опциональных параметров. Конструктор New(req, opts...). Расширяется без breaking changes.

  13. Что такое Service Locator и почему это antipattern в Go? Глобальный контейнер, откуда код «достаёт» зависимости. Скрывает связи, ломает тестирование, нарушает explicit dependencies.

  14. Где должны жить интерфейсы repository — в domain или infrastructure? Domain (или use case). Реализация — в infrastructure. Это Dependency Inversion: high-level не зависит от low-level.

  15. Что такое Domain Event? Факт, произошедший в домене (OrderCreated). Используется для интеграции и saga.

  16. Чем Aggregate отличается от Entity? Aggregate — кластер entities+VO с одной границей транзакционной консистентности. Aggregate Root — entity-точка входа.

  17. Можно ли в Aggregate ссылаться на другой Aggregate по объекту? Нет, только по ID. Иначе теряется граница aggregates.

  18. Как DI помогает тестированию? Подменяем реальные зависимости на mock/fake, тест use case не требует БД/сети.

  19. Можно ли в use case вызывать другой use case? Не рекомендуется. Пересекающаяся логика — в domain service.

  20. Что такое fx.Lifecycle и OnStop? Хук для graceful shutdown компонента. fx вызывает OnStop в обратном порядке при остановке.

  21. Чем wire отличается от dig? dig — runtime DI-контейнер (основа fx). wire — генератор compile-time кода.

  22. Что лучше — config struct или functional options? Зависит от: required+несколько → struct; много optional с defaults → options. Часто комбинация.

  23. Как обработать domain event? Entity накапливает события (order.events), use case достаёт через PullEvents() и публикует через EventPublisher (Kafka, in-memory bus).

  24. Что такое Repository в DDD? Абстракция доступа к persistence для Aggregate Root. Save, GetByID, иногда поиск по бизнес-критериям.

  25. Чем DDD отличается от Clean Architecture? DDD — про моделирование домена (entity, aggregate, BC). Clean Arch — про слои и зависимости. Часто используются вместе.


  1. Опишите aggregate Order с инвариантами:

    • нельзя создать пустой заказ;
    • нельзя отменить отгруженный;
    • сумма заказа = сумма (price * qty) по items. Покройте unit-тестами.
  2. Сделайте Value Object Money с операциями Add, Subtract, Multiply(int), Equal. Покажите, что Money immutable.

  3. Возьмите проект на manual DI и переведите его на wire. Сравните main.go до/после.

  4. То же — на fx. Добавьте lifecycle для HTTP-сервера, БД-пула и Kafka producer.

  5. Напишите DI-обёртку с functional options для HTTP-сервера: WithReadTimeout, WithLogger, WithMiddleware(...).

  6. Реализуйте Pricer как domain service: бесплатная доставка при сумме >5000, скидка 10% premium-пользователю. Тест без БД/HTTP.