Разберём, как checkout.UseCase объявляет зависимости, адаптеры переводят типы и ошибки между order, catalog и платёжным клиентом, а Composition Root связывает конкретные реализации.
Это третья статья серии «Архитектура Go». Первая часть посвящена принадлежности sentinel-ошибок, а вторая рассказывает про общую транзакцию для нескольких репозиториев.
Словом «домен» здесь называется модуль монолита, отвечающий за отдельную функциональность: заказы, каталог или кампании. Это не обязательно отдельная предметная область в смысле DDD. Внутри модуля могут находиться бизнес-код и инфраструктурные адаптеры, но остальное приложение видит только намеренно открытые типы и методы.
После Ruby локальный интерфейс можно сравнить с duck typing. Рядом с checkout.UseCase объявляются только нужные ему методы, а конкретный объект их предоставляет. В Ruby несовпадение обнаружится во время вызова, а Go проверит набор методов при сборке.
Во всех примерах используется условный Go-модуль modular_shop. Импорты приведены там, где помогают увидеть связь между пакетами.
Содержание
- Как прямые импорты связывают модули
- Use case объявляет нужные зависимости
- Модуль использует собственные типы
- Как устроен модуль order
- Адаптер связывает catalog и order
- Где компилятор проверяет интерфейс
- Composition root собирает приложение
- Когда подход полезен
- Практические правила
Как прямые импорты связывают модули
Рассмотрим сценарий оформления заказа. Ему нужны товар из каталога, сохранение заказа и списание оплаты. Самый короткий путь это обратиться напрямую к готовым реализациям:
// internal/domain/order/checkout/usecase.go
package checkout
import (
"modular_shop/internal/clients/paymentclient"
"modular_shop/internal/domain/catalog/repositories/postgres"
)
type UseCase struct {
productRepo *postgres.ProductRepository
paymentClient *paymentclient.Client
}
Сначала это выглядит практично, но контракт checkout.UseCase теперь определяют поставщики:
- каталог решает, какой тип товара увидит
checkout.UseCase; - платёжный клиент передаёт наверх свою структуру HTTP-ответа, или DTO;
- репозиторий становится частью бизнес-слоя;
- тесты
checkout.UseCaseзависят от широкого API конкретных реализаций.
Если платёжный сервис переименует поле transaction_id, изменение затронет не только HTTP-клиент, но и оформление заказа. Если каталог изменит модель хранения, её поля могут разойтись по другим модулям.
Запрещать импорты как таковые бессмысленно: пакеты Go для этого и существуют. Проблема возникает, когда checkout.UseCase зависит не от нужных ему возможностей каталога, а от устройства конкретного репозитория или модели хранения.
Use case объявляет нужные зависимости
Сначала посмотрим только на сценарий оформления заказа. Ему нужно сохранить заказ, получить товар из catalog и вызвать оплату. checkout.UseCase описывает эти зависимости интерфейсами с теми методами, которые использует сам.
В Go интерфейс обычно объявляет пакет, которому нужны определённые действия. Официальные Go Code Review Comments рекомендуют держать интерфейсы у потребителя, а поставщику возвращать конкретный тип.
Рядом с checkout.UseCase объявлены только операции, которые ему нужны от других модулей и внешних сервисов:
// internal/domain/order/checkout/usecase.go
package checkout
import (
"context"
"fmt"
"github.com/google/uuid"
"modular_shop/internal/domain/order"
)
type orderRepository interface {
Create(
ctx context.Context,
params order.CreateOrderParams,
) (order.Order, error)
MarkPaid(
ctx context.Context,
orderID uuid.UUID,
paymentID string,
) error
}
type productPort interface {
GetProduct(
ctx context.Context,
productID uuid.UUID,
) (order.ProductSnapshot, error)
}
type paymentClient interface {
Charge(
ctx context.Context,
command order.ChargeCommand,
) (order.ChargeResult, error)
}
type Command struct {
ProductID uuid.UUID
AccountID uuid.UUID
}
type UseCase struct {
orderRepo orderRepository
productRepo productPort
paymentClient paymentClient
}
func New(
orderRepo orderRepository,
productRepo productPort,
paymentClient paymentClient,
) *UseCase {
return &UseCase{
orderRepo: orderRepo,
productRepo: productRepo,
paymentClient: paymentClient,
}
}
func (uc *UseCase) Execute(
ctx context.Context,
command Command,
) (order.Order, error) {
product, err := uc.productRepo.GetProduct(ctx, command.ProductID)
if err != nil {
return order.Order{}, fmt.Errorf("get product: %w", err)
}
createdOrder, err := uc.orderRepo.Create(ctx, order.CreateOrderParams{
AccountID: command.AccountID,
TotalCents: product.PriceCents,
})
if err != nil {
return order.Order{}, fmt.Errorf("create order: %w", err)
}
payment, err := uc.paymentClient.Charge(ctx, order.ChargeCommand{
IdempotencyKey: createdOrder.ID.String(),
AccountID: command.AccountID,
Amount: product.PriceCents,
})
if err != nil {
return createdOrder, fmt.Errorf("charge order: %w", err)
}
if err := uc.orderRepo.MarkPaid(
ctx,
createdOrder.ID,
payment.PaymentID,
); err != nil {
return createdOrder, fmt.Errorf("mark order paid: %w", err)
}
createdOrder.Status = order.StatusPaid
return createdOrder, nil
}
Execute показывает весь сценарий: checkout.UseCase получает товар через productPort, сохраняет заказ через orderRepository и вызывает оплату через paymentClient. Эти интерфейсы доступны только пакету checkout. Внешний код вызывает checkout.New и передаёт объекты, соответствующие этим интерфейсам.
Вызов платёжного сервиса и изменение заказа не образуют общую транзакцию. Здесь код нужен, чтобы показать зависимости checkout.UseCase; сбои между Charge и MarkPaid, повторные запросы и промежуточные статусы разобраны в разделе о границах SQL-транзакции.
Имя productPort описывает межмодульную границу для checkout.UseCase. Оно не привязано к адаптеру или к устройству catalog. orderRepository тоже объявлен рядом с use case, но это внутренняя зависимость модуля order, поэтому это не port.
Пока неважно, какие структуры реализуют эти интерфейсы и где они создаются. На этом этапе достаточно увидеть контракт checkout.UseCase: репозиторий умеет создать и обновить заказ, catalog предоставляет товар, платёжный клиент списывает деньги.
Создавать интерфейс для каждой структуры заранее не нужно. Сначала появляется код, которому действительно требуется зависимость. Затем рядом с этим кодом объявляется минимальный интерфейс. До появления потребителя мы лишь гадаем, какие методы понадобятся.
Модуль использует собственные типы
Расположение интерфейса рядом с checkout.UseCase ещё не изолирует его от HTTP-клиента. Если paymentClient возвращает paymentclient.ChargeResponse, пакету checkout всё равно придётся импортировать пакет клиента, а checkout.UseCase будет зависеть от полей его DTO.
Пакет order объявляет собственные данные для разных частей сценария оформления заказа. Файлы находятся в одной директории и объявляют один пакет, но каждый отвечает за свою часть контракта.
order.go описывает сам заказ, его статус и параметры создания:
// internal/domain/order/order.go
package order
import (
"time"
"github.com/google/uuid"
)
type Status string
const (
StatusPending Status = "pending"
StatusPaid Status = "paid"
)
type Order struct {
ID uuid.UUID
AccountID uuid.UUID
Status Status
TotalCents int64
CreatedAt time.Time
}
type CreateOrderParams struct {
AccountID uuid.UUID
TotalCents int64
}
Status объявлен отдельным строковым типом, поэтому в order.Order нельзя записать произвольную строку. CreateOrderParams содержит только те поля, которые приходят снаружи: идентификатор заказа и время создания назначает репозиторий.
product.go описывает только снимок товара, который нужен оформлению заказа:
// internal/domain/order/product.go
package order
import "github.com/google/uuid"
type ProductSnapshot struct {
ID uuid.UUID
Name string
PriceCents int64
}
payment.go содержит контракт, с которым checkout.UseCase обращается к платёжному адаптеру:
// internal/domain/order/payment.go
package order
import "github.com/google/uuid"
type ChargeCommand struct {
IdempotencyKey string
AccountID uuid.UUID
Amount int64
}
type ChargeResult struct {
PaymentID string
}
Ошибки, которые возвращает order, объявлены в errors.go:
// internal/domain/order/errors.go
package order
import "errors"
var (
ErrOrderNotFound = errors.New("order not found")
ErrOrderPersistenceFailed = errors.New("order persistence failed")
ErrProductUnavailable = errors.New("product unavailable")
ErrProductLookupFailed = errors.New("product lookup failed")
ErrProductReservationFailed = errors.New("product reservation failed")
ErrPaymentDeclined = errors.New("payment declined")
ErrPaymentFailed = errors.New("payment failed")
)
Первые две возвращает репозиторий заказов, остальные приходят от адаптеров catalog и платёжного клиента. Лежат они в одном файле, потому что ошибка принадлежит контракту order, а не тому, кто её произвёл: вызывающий код должен отличить отсутствующий товар от сбоя базы, ничего не зная про GORM и про существование catalog. Реализации только переводят в эти ошибки свои сбои, поэтому errors.Is не тянет за собой чужой пакет.
В ChargeResult остаётся только идентификатор платежа, нужный оформлению заказа. Заголовки ответа, код провайдера и служебные поля API не становятся частью контракта order.
То же правило действует между бизнес-кодом и базой данных. order.Order может содержать статус и бизнес-идентификатор, но ему не обязательно знать про sql.NullTime, имя таблицы или настройки связей GORM.
Новый тип нужен не для каждого вызова. Он оправдан на границе, если два модуля могут изменяться независимо или по-разному понимают одни и те же данные.
Как устроен модуль order
Теперь можно посмотреть на структуру целиком:
internal/
domain/
catalog/
...
order/
order.go # заказ и его статус
product.go # данные товара для оформления заказа
payment.go # команда и результат оплаты
errors.go # ошибки модуля
checkout/
usecase.go # сценарий и нужные ему интерфейсы
adapters/
catalog.go # адаптер catalog
payment.go # адаптер payment client
repositories/
postgres/
models.go # строки таблиц GORM
repository.go # сохранение заказов
payment/
...
app/
container.go # создание и связывание объектов
Пути указаны относительно корня Go-модуля. checkout/usecase.go содержит сценарий и его зависимости. Корневые файлы пакета order содержат собственные типы и ошибки. В adapters находится код, который знает контракты обеих сторон и переводит данные между ними. PostgreSQL-репозиторий остаётся внутри order, а конкретные объекты связываются в app/container.go.
order.Order описывает заказ для бизнес-кода. postgres.orderRow описывает строку базы данных и остаётся приватной структурой репозитория. Ответ платёжного API остаётся внутри HTTP-клиента и адаптера.
Адаптер связывает catalog и order
checkout.UseCase не зависит от типов catalog или order/adapters. Рядом с этой структурой объявлен productPort, через который сценарий обращается к другому модулю.
Связь проходит через один метод интерфейса:
internal/domain/order/checkout/usecase.go
checkout.UseCase
└ productRepo productPort
└ GetProduct(...)
▲
│ этот метод входит в port
internal/domain/order/adapters/catalog.go
CatalogAdapter │
├ GetProduct(...) ─────┘
├ ReserveProduct(...) checkout.UseCase не видит этот метод
└ вызывает
▼
internal/domain/catalog/product_usecase.go
catalog.ProductUseCase
internal/app/container.go
checkout.New(..., catalogAdapter, ...)
checkout.UseCase хранит зависимость в поле типа productPort и вызывает только GetProduct. Пакет checkout не импортирует catalog или order/adapters. У CatalogAdapter также есть ReserveProduct, но этого метода нет в productPort, поэтому текущий use case его не видит.
CatalogAdapter лежит в order/adapters, потому что переводит данные в модель order. Этот пакет импортирует catalog и корневой пакет order, вызывает use case каталога и преобразует результат в типы order.
Правило про интерфейс у потребителя действует и здесь, на уровень ниже. Потребитель catalog в этом месте сам адаптер, поэтому он объявляет productCatalog с двумя нужными ему методами, а не принимает *catalog.ProductUseCase целиком:
// internal/domain/order/adapters/catalog.go
package adapters
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"modular_shop/internal/domain/catalog"
"modular_shop/internal/domain/order"
)
type productCatalog interface {
Get(
ctx context.Context,
productID uuid.UUID,
) (catalog.Product, error)
Reserve(
ctx context.Context,
productID uuid.UUID,
quantity int,
) error
}
type CatalogAdapter struct {
products productCatalog
}
func NewCatalogAdapter(products productCatalog) *CatalogAdapter {
return &CatalogAdapter{products: products}
}
func (a *CatalogAdapter) GetProduct(
ctx context.Context,
productID uuid.UUID,
) (order.ProductSnapshot, error) {
product, err := a.products.Get(ctx, productID)
if errors.Is(err, catalog.ErrProductNotFound) {
return order.ProductSnapshot{}, fmt.Errorf(
"get product %s: %w",
productID,
order.ErrProductUnavailable,
)
}
if err != nil {
return order.ProductSnapshot{}, fmt.Errorf(
"get product %s: %w: %v",
productID,
order.ErrProductLookupFailed,
err,
)
}
return order.ProductSnapshot{
ID: product.ID,
Name: product.Name,
PriceCents: product.PriceCents,
}, nil
}
func (a *CatalogAdapter) ReserveProduct(
ctx context.Context,
productID uuid.UUID,
quantity int,
) error {
err := a.products.Reserve(ctx, productID, quantity)
if errors.Is(err, catalog.ErrProductNotFound) {
return fmt.Errorf(
"reserve %d units of product %s: %w",
quantity,
productID,
order.ErrProductUnavailable,
)
}
if err != nil {
return fmt.Errorf(
"reserve %d units of product %s: %w: %v",
quantity,
productID,
order.ErrProductReservationFailed,
err,
)
}
return nil
}
От импорта catalog это не избавляет: адаптер по-прежнему принимает catalog.Product и сравнивает ошибку с catalog.ErrProductNotFound. Интерфейс сужает не импорт, а набор методов. Правило то же, что и у productPort в checkout: контракт объявлен там, где используется, и содержит только вызываемые методы.
Адаптер преобразует catalog.Product в order.ProductSnapshot и возвращает только ошибки, которыми владеет order. catalog.ErrProductNotFound превращается в order.ErrProductUnavailable. Остальные сбои Get классифицируются как order.ErrProductLookupFailed, а сбои Reserve как order.ErrProductReservationFailed.
Идентификатор товара, количество и исходный текст ошибки остаются в подробном сообщении. Ошибка catalog добавляется через %v, а не через %w: errors.Is видит ошибку order, но внешний код не начинает зависеть от sentinel-ошибок catalog.
Поля товара, которые не нужны checkout.UseCase, адаптер не копирует. ReserveProduct вызывает отдельную операцию catalog и переводит её ошибки по тому же правилу.
Конкретный *adapters.CatalogAdapter имеет оба метода, но для соответствия productPort достаточно GetProduct. ReserveProduct может понадобиться другому use case со своим локальным port. В checkout.UseCase этот метод недоступен, потому что его нет в productPort.
Если catalog и order считаются разными bounded contexts со своими моделями, CatalogAdapter выполняет часть работы Anti-Corruption Layer. Он не даёт типам и ошибкам catalog распространиться в order. Полный ACL может включать несколько адаптеров, трансляторов и фасадов, поэтому один адаптер не всегда представляет весь слой.
Дублирование catalog.Product и order.ProductSnapshot здесь намеренное. В каталоге товар может иметь десятки атрибутов, а checkout.UseCase нужны только идентификатор, название и цена на момент оформления.
С catalog границу держать проще всего: модуль может принадлежать другой команде, но обе стороны собираются вместе, поэтому контракт меняется по договорённости, а несовпадение находит компилятор. За пределами сборки не остаётся ни того, ни другого: контракт диктует провайдер, а компилятор про его сторону не знает ничего. Что из этого следует для адаптера, разобрано на платёжном клиенте в следующей статье.
Где компилятор проверяет интерфейс
Конкретный тип удовлетворяет интерфейсу, если имеет нужный набор методов. Проверка происходит, когда значение присваивается интерфейсу или передаётся как аргумент соответствующего типа.
Конструктор checkout.UseCase принимает три интерфейса из полного листинга выше. На этом уровне нет импортов PostgreSQL-репозитория, catalog или платёжного HTTP-клиента.
Composition root вызывает его с конкретными реализациями:
// internal/app/container.go
checkoutUseCase := checkout.New(
orderRepository,
catalogAdapter,
paymentAdapter,
)
Именно во время этого вызова компилятор проверит сигнатуры всех трёх зависимостей. *orderpostgres.Repository должен соответствовать orderRepository, *orderadapters.CatalogAdapter должен соответствовать productPort, а *orderadapters.PaymentAdapter должен соответствовать paymentClient. Остальные методы конкретных типов на эту проверку не влияют.
Компилятор также запрещает циклические импорты. Но прямой импорт catalog из order, не создающий цикл, скомпилируется. Архитектурное правило «модули не импортируют друг друга» приходится проверять линтером в CI.
Composition root собирает приложение
Composition Root это место, где создаются конкретные клиенты, репозитории и сценарии. В небольшом Go-приложении обычно main или пакет internal/app.
// internal/app/container.go
func NewContainer(cfg Config) (*Container, error) {
db, err := openPostgres(cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("open postgres: %w", err)
}
orderRepository := orderpostgres.New(db)
productUseCase := catalog.NewProductUseCase(
catalogpostgres.New(db),
)
paymentClient := paymentclient.New(cfg.PaymentURL)
catalogAdapter := orderadapters.NewCatalogAdapter(productUseCase)
paymentAdapter := orderadapters.NewPaymentAdapter(paymentClient)
checkoutUseCase := checkout.New(
orderRepository,
catalogAdapter,
paymentAdapter,
)
return &Container{Checkout: checkoutUseCase}, nil
}
Контейнер импортирует все стороны, потому что здесь приложение связывает реализации. Внутри order доступ к catalog и HTTP-клиенту сосредоточен в order/adapters. Пакет checkout и остальные пакеты с бизнес-логикой их не импортируют.
Composition root также показывает реальные зависимости сценария. Если конструктор принимает десять объектов, это повод проверить, не выполняет ли use case слишком много задач.
Когда подход полезен
Я бы использовал такую структуру, когда:
- монолит содержит несколько функциональных областей;
- разные части приложения меняются с разной скоростью;
- один сценарий использует несколько репозиториев и внешних сервисов;
- модели хранения отличаются от бизнес-моделей;
- прямые импорты уже создают циклы или каскадные изменения.
В небольшом CRUD-приложении с несколькими пакетами локальные DTO и адаптеры, скорее всего, только увеличат объём кода. Признаки преждевременной изоляции собраны в разделе о цене границ.
Новая папка сама по себе не создаёт самостоятельный модуль. Нужно определить его открытый контракт, владельца данных, ошибки и способ взаимодействия с остальным приложением.
Практические правила
- Интерфейс для другого модуля объявлен рядом с use case, который его использует.
- В межмодульном интерфейсе нет типов GORM и DTO внешнего клиента.
- Модель хранения остаётся внутри репозитория.
- Адаптер преобразует типы и ожидаемые ошибки на границе.
- Конкретные реализации встречаются в composition root.
- Новый собственный тип добавлен только там, где части приложения меняются независимо.
Хорошую границу видно по последствиям изменений. Правка схемы PostgreSQL затрагивает PostgreSQL-репозиторий, новый формат платёжного API затрагивает payment-адаптер, а изменение правила оформления заказа относится к checkout.UseCase. Если из-за одного поля приходится править все три места, граница протекает.
В следующей части разберём production-сторону этой схемы: транзакционные ограничения, преобразование моделей хранения, проверку импортов через Depguard и цену изоляции. Тестам этой схемы посвящены пятая и шестая части.
Материалы
- официальные рекомендации Go по размещению интерфейсов;
- спецификация Go об интерфейсах и наборах методов;
- рекомендации Go по организации модуля;
- определение Composition Root Марка Симана;
- Gateway и Anti-Corruption Layer;
- sentinel-ошибки и принадлежность контракта;
- транзакции для нескольких репозиториев;
- тесты для границ модулей;
- интеграционные тесты с PostgreSQL.