Опирается на правила:
R-HEX-AOUT-1…R-HEX-AOUT-4иR-HEX-AOUT-X1…R-HEX-AOUT-X4из Hexagonal Rules → раздел 6. Adapters out.
Важно знать
- На каждую внешнюю систему — отдельный пакет:
adapter/out/persistence/,adapter/out/sber/,adapter/out/odna_kassa/,adapter/out/kafka/.- Адаптер реализует
interfaceизcore/<bc>/port/out/. Compile-time assertion:var _ out.PaymentPort = (*PaymentAdapter)(nil).- В пакете адаптера живёт mapper — переводит
RegisterPaymentCommand(domain) вSberRegisterRequest(system-DTO) и обратно.- Out-adapter знает свою инфраструктуру:
adapter/out/sber/знаетnet/http-клиент и Sber-DTO;adapter/out/persistence/знаетpgxи sqlc-generated structs. Адаптеры не видят друг друга.- Ошибки — значения:
SberErrorв пакете адаптера оборачивает системную ошибку; handler в core ловит черезerrors.Asпо*out.PaymentPortError.- Бизнес-логика в out-adapter — запрещена. Адаптер транслирует и вызывает, handler решает.
- Один пакет — один порт одного домена. Никаких «универсальных» адаптеров через несколько BC.
- Координация двух адаптеров — это use case в
core/: handler инжектит два порта через интерфейсы.
Out-adapter — это «выход» сервиса во внешний мир. Он принимает domain-вызов через port-interface, транслирует в вызов конкретной системы (HTTP к Sber, SQL через sqlc, запись в Kafka), получает ответ, возвращает domain-результат. На этом его ответственность заканчивается.
Per-system пакеты
R-HEX-AOUT-1 — на каждую внешнюю систему свой пакет, не один общий adapter/out/.
internal/
adapter/out/
persistence/ # OrderRepository через sqlc + pgx (R-HEX-AOUT)
sber/ # PaymentPort через Sber REST API
odna_kassa/ # PaymentPort через OdnaKassa (альтернатива)
kafka/ # EventPublisher через kafka-go
s3/ # StoragePort через S3-клиент
Почему важно разделять:
- Изоляция зависимостей.
adapter/out/sber/импортирует толькоnet/http+ Sber-DTO;adapter/out/persistence/— толькоpgx+ sqlc. Смена SMS-провайдера затрагивает ровно один пакет. - Per-system circuit breaker. Resilience настраивается на клиент конкретной системы — сбой Sber не роняет весь исходящий трафик.
- Тестируемость.
httptest.NewServerдля Sber поднимается в тестахadapter/out/sber/; контейнер PG — в тестахadapter/out/persistence/. Не один мок-сервер на всё. - Наблюдаемость.
slogи метрики пишутся с атрибутом системы (system=sber,system=persistence) — гранулярные дашборды без смешивания.
Адаптер реализует port-interface
R-HEX-AOUT-2 — структура адаптера реализует interface, объявленный в core/<bc>/port/out/.
// core/order/port/out/payment_port.go
package out
type PaymentPort interface {
Register(ctx context.Context, cmd RegisterPaymentCommand) (RegisterPaymentResult, error)
Cancel(ctx context.Context, paymentID PaymentID) error
}
type RegisterPaymentCommand struct {
OrderID OrderID
CustomerID CustomerID
Amount Money
}
type RegisterPaymentResult struct {
PaymentID PaymentID
ConfirmedAt time.Time
}
// adapter/out/sber/payment_adapter.go
package sber
type PaymentAdapter struct {
client *Client
mapper PaymentMapper
log *slog.Logger
}
var _ out.PaymentPort = (*PaymentAdapter)(nil) // compile-time assertion
func NewPaymentAdapter(client *Client, log *slog.Logger) *PaymentAdapter {
return &PaymentAdapter{client: client, log: log}
}
func (a *PaymentAdapter) Register(ctx context.Context, cmd out.RegisterPaymentCommand) (out.RegisterPaymentResult, error) {
req := a.mapper.ToSberRequest(cmd)
resp, err := a.client.RegisterPayment(ctx, req)
if err != nil {
return out.RegisterPaymentResult{}, &SberError{Op: "register", Err: err}
}
return a.mapper.ToDomainResult(resp), nil
}
func (a *PaymentAdapter) Cancel(ctx context.Context, paymentID out.PaymentID) error {
if err := a.client.CancelPayment(ctx, string(paymentID)); err != nil {
return &SberError{Op: "cancel", Err: err}
}
return nil
}
var _ out.PaymentPort = (*PaymentAdapter)(nil) — Go-идиома compile-time проверки: если адаптер не реализует все методы интерфейса, сборка упадёт с понятным сообщением.
Аналогично — persistence-адаптер через sqlc:
// adapter/out/persistence/order_repository.go
package persistence
type OrderRepository struct {
q *db.Queries // sqlc-generated
log *slog.Logger
}
var _ out.OrderRepository = (*OrderRepository)(nil)
func NewOrderRepository(pool *pgxpool.Pool, log *slog.Logger) *OrderRepository {
return &OrderRepository{q: db.New(pool), log: log}
}
func (r *OrderRepository) FindByID(ctx context.Context, id out.OrderID) (aggregate.Order, error) {
row, err := r.q.GetOrder(ctx, string(id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return aggregate.Order{}, &out.OrderNotFoundError{OrderID: id}
}
return aggregate.Order{}, fmt.Errorf("persistence: find order %s: %w", id, err)
}
return OrderMapper{}.ToDomain(row), nil
}
func (r *OrderRepository) Save(ctx context.Context, order aggregate.Order) error {
params := OrderMapper{}.ToInsertParams(order)
if err := r.q.UpsertOrder(ctx, params); err != nil {
return fmt.Errorf("persistence: save order %s: %w", order.ID(), err)
}
return nil
}
db.Queries и db.GetOrderRow — это sqlc-generated типы, они существуют только в пакете adapter/out/persistence/. В core/ они не проникают — это enforced архитектурным тестом.
Mapper в пакете адаптера
R-HEX-AOUT-3 — mapper — отдельная структура в пакете адаптера, переводит между domain (port-сигнатура) и system-DTO.
// adapter/out/sber/payment_mapper.go
package sber
type PaymentMapper struct{}
func (PaymentMapper) ToSberRequest(cmd out.RegisterPaymentCommand) SberRegisterRequest {
return SberRegisterRequest{
OrderID: string(cmd.OrderID),
Amount: cmd.Amount.Kopecks(), // Sber принимает копейки
Currency: "RUB",
Customer: SberCustomer{
ID: string(cmd.CustomerID),
},
}
}
func (PaymentMapper) ToDomainResult(resp SberRegisterResponse) out.RegisterPaymentResult {
return out.RegisterPaymentResult{
PaymentID: out.PaymentID(resp.PaymentID),
ConfirmedAt: resp.CreatedAt,
}
}
// adapter/out/persistence/order_mapper.go
package persistence
type OrderMapper struct{}
func (OrderMapper) ToDomain(row db.GetOrderRow) aggregate.Order {
return aggregate.Restore(
aggregate.OrderID(row.ID),
aggregate.CustomerID(row.CustomerID),
mapItems(row.Items),
mapStatus(row.Status),
value_object.NewMoney(row.TotalAmount, row.Currency),
)
}
func (OrderMapper) ToInsertParams(o aggregate.Order) db.UpsertOrderParams {
return db.UpsertOrderParams{
ID: string(o.ID()),
CustomerID: string(o.CustomerID()),
Status: string(o.Status()),
TotalAmount: o.Total().Amount(),
Currency: o.Total().Currency(),
}
}
Mapper знает специфику системы: копейки против рублей, числовые коды статусов, форматы дат. Всё это — деталь адаптера, не утекает в core/.
Ошибки-значения в out-adapter
Ошибки объявлены в двух слоях:
// core/order/port/out/errors.go — port-ошибка в core
package out
type PaymentPortError struct {
Op string
Err error
}
func (e *PaymentPortError) Error() string { return "payment port: " + e.Op + ": " + e.Err.Error() }
func (e *PaymentPortError) Unwrap() error { return e.Err }
func (e *PaymentPortError) Kind() apperr.Kind { return apperr.Integration }
type OrderNotFoundError struct {
OrderID OrderID
}
func (e *OrderNotFoundError) Error() string { return "order not found: " + string(e.OrderID) }
func (e *OrderNotFoundError) Kind() apperr.Kind { return apperr.Domain }
// adapter/out/sber/errors.go — системная ошибка в адаптере
package sber
type SberError struct {
Op string
Err error
}
func (e *SberError) Error() string { return "sber: " + e.Op + ": " + e.Err.Error() }
func (e *SberError) Unwrap() error { return e.Err }
func (e *SberError) Kind() apperr.Kind { return apperr.Integration }
Handler в core/ ловит через errors.As по типу из core/, не по SberError:
// core/order/usecase/confirm_order.go
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd ConfirmOrderCommand) error {
result, err := h.payments.Register(ctx, out.RegisterPaymentCommand{
OrderID: cmd.OrderID,
Amount: order.Total(),
})
if err != nil {
var portErr *out.PaymentPortError
if errors.As(err, &portErr) {
slog.Error("payment registration failed", "op", portErr.Op, "err", portErr.Err)
}
return fmt.Errorf("confirm order: register payment: %w", err)
}
// ...
}
Handler не знает о SberError. Если завтра Sber сменится на OdnaKassa, handler не меняется — только адаптер.
Что adapter знает и не знает
R-HEX-AOUT-4:
| Пакет | Знает | Не знает |
|---|---|---|
adapter/out/persistence/ | pgxpool, pgx, sqlc-generated типы, slog | chi, net/http, kafka-go, Sber-DTO |
adapter/out/sber/ | net/http, Sber-DTO, encoding/json | pgx, sqlc, kafka-go, OdnaKassa-DTO |
adapter/out/odna_kassa/ | net/http, OdnaKassa-DTO | pgx, Sber-DTO |
adapter/out/kafka/ | kafka-go, Avro/JSON serialization | pgx, net/http, Sber |
Это соблюдается конвенцией и проверяется архитектурным тестом: adapter/out/sber/ не должен импортировать adapter/out/persistence/ — и наоборот.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Port-метод возвращает SberRegisterResponse | R-HEX-AOUT-X1 | Mapper переводит в RegisterPaymentResult внутри адаптера, наружу — только domain-тип |
if sberResp.Code == 4 { sendNotification(...) } в адаптере | R-HEX-AOUT-X2 | Адаптер возвращает ошибку; handler в core интерпретирует через errors.As и сам вызывает нужный порт |
UniversalAdapter implements PaymentPort + SmsPort + StoragePort | R-HEX-AOUT-X3 | Три отдельных пакета с отдельными клиентами, circuit breaker'ами, тестами |
SberAdapter инжектит OdnaKassaAdapter | R-HEX-AOUT-X4 | Handler в core инжектит оба порта (PaymentPort + RefundPort), координирует через use case |
Пример нарушения R-HEX-AOUT-X2 и правильная альтернатива:
// AVOID — бизнес-логика и координация в адаптере
func (a *PaymentAdapter) Register(ctx context.Context, cmd out.RegisterPaymentCommand) (out.RegisterPaymentResult, error) {
if cmd.Amount.Kopecks() > 10_000_000 { // бизнес-правило в адаптере
return out.RegisterPaymentResult{}, &out.PaymentPortError{Op: "register", Err: errors.New("amount too large")}
}
resp, err := a.client.RegisterPayment(ctx, a.mapper.ToSberRequest(cmd))
if err != nil {
return out.RegisterPaymentResult{}, &SberError{Op: "register", Err: err}
}
if resp.Status == 4 { // интерпретация результата в адаптере
a.notifier.SendAlert(ctx, cmd.OrderID) // side-effect через другой адаптер
}
return a.mapper.ToDomainResult(resp), nil
}
// PREFER — адаптер транслирует, handler решает
func (a *PaymentAdapter) Register(ctx context.Context, cmd out.RegisterPaymentCommand) (out.RegisterPaymentResult, error) {
resp, err := a.client.RegisterPayment(ctx, a.mapper.ToSberRequest(cmd))
if err != nil {
return out.RegisterPaymentResult{}, &SberError{Op: "register", Err: err}
}
return a.mapper.ToDomainResult(resp), nil
}
Куда дальше
- Ports — что именно реализует out-adapter, как объявить
interfaceи port-ошибки вcore/. - Adapters in — симметричная сторона: chi-handler → mapper →
UseCase.Handle. - Core layer — что разрешено и запрещено импортировать в
core/<bc>/. - Module structure — package-конвенция и архитектурный тест на import-нарушения.
- Bootstrap / composition root — где создаются адаптеры и как передаются в handler'ы.
- Architecture tests —
packages.Load+ forbidden-imports как required CI-check. - When to use Hexagonal — когда per-system пакеты оправданы, а когда плоский
internal/достаточен.