Когда сервис разрастается, в нём начинает скапливаться запутанная смесь: HTTP-обработчики с SQL-запросами внутри, функции с зависимостями на конкретный драйвер базы данных, бизнес-правила, перемешанные с деталями сериализации. Изменить одно — сломать другое. Протестировать логику — поднять половину инфраструктуры.
Гексагональная архитектура решает это разделением: весь бизнес-смысл сервиса живёт в core/, изолированный от любой инфраструктуры. В этой статье разберём, что туда входит, как это организовать и почему именно так.
Что такое core слой
core/ — сердце сервиса. Здесь живут агрегаты, бизнес-правила, инварианты, события. Всё, что не зависит ни от какой инфраструктуры: core работает без HTTP-роутера, без драйвера базы данных, без брокера сообщений.
На практике сервис запускается на chi + pgx + Kafka, но core этого не знает. Он получает то, что ему нужно, через интерфейсы — a адаптеры снаружи их реализуют.
Разрешённые зависимости core — только стандартная библиотека Go: context, errors, time, fmt, strings, math. Плюс внутренний пакет core/apperr для типизированных ошибок.
Запрещено всё, что относится к инфраструктуре: chi, pgx, go-redis, kafka-go, slog, sqlc-generated типы, HTTP-DTO. Если в core/ появился такой импорт — что-то лежит не в том месте.
Структура core/
Типичная раскладка для bounded context order:
internal/
core/
apperr/
kind.go # apperr.Kind: Domain, Validation, Integration, Technical
errors.go # KindOf, NewValidation, Categorized interface
order/
aggregate/
order.go # Order aggregate (rich domain)
order_item.go # OrderItem entity
value_object/
money.go # Money VO
order_id.go # OrderID типизированный alias
event/
order_confirmed.go # OrderConfirmed domain event
port/out/
order_repo.go # OrderRepository interface
payment_port.go # PaymentPort interface
errors.go # PaymentPortError (port-ошибка)
usecase/
confirm_order.go # ConfirmOrderCommand + ConfirmOrderHandler
create_order.go # CreateOrderCommand + CreateOrderHandler
service/
pricing_service.go # Domain Service (если нужна кросс-aggregate логика)
customer/
aggregate/
customer.go
port/out/
customer_repo.go
Что в каждой папке:
aggregate/— Aggregate Root и входящие в него сущности. Агрегат инкапсулирует инварианты и бизнес-правила.value_object/— неизменяемые типы (Money,OrderID,CustomerEmail). Сравниваются по значению, не по указателю.port/out/— интерфейсы: «что core нужно от внешнего мира». Репозитории, клиенты внешних систем, публикаторы событий.usecase/— Command + Handler пары. Handler координирует: загрузить агрегат → вызвать domain-метод → сохранить.service/— общая domain-логика, которая не помещается в один агрегат. Используется редко.
Бизнес-логика внутри агрегата
Частая ошибка — агрегат как просто контейнер данных, а вся логика в OrderService. Это называется «анемичная модель» и создаёт серьёзные проблемы.
Посмотрим на разницу.
Анемичный вариант — логика снаружи агрегата:
// ИЗБЕГАТЬ — агрегат без логики, логика в Service
type Order struct {
Status Status
Items []OrderItem
Total Money
}
type OrderService struct {
orders OrderRepository
payments PaymentPort
}
func (s *OrderService) ConfirmOrder(ctx context.Context, id OrderID) error {
order, _ := s.orders.FindByID(ctx, id)
if order.Status != StatusPending { // инвариант снаружи агрегата
return errors.New("wrong status")
}
if len(order.Items) == 0 { // дублируется в Kafka-обработчике, в admin-handler'е
return errors.New("no items")
}
order.Status = StatusConfirmed
return s.orders.Save(ctx, order)
}
Проблемы:
- Проверки
Confirmразбросаны по HTTP-обработчику, Kafka-потребителю, admin-CLI — одна из копий рано или поздно отстанет. - Протестировать саму логику заказа нельзя без инфраструктуры.
- Чтобы понять, когда заказ становится
Confirmed, нужно обойти все методы всех сервисов.
Правильный вариант — логика внутри агрегата:
// internal/core/order/aggregate/order.go
package aggregate
import (
"errors"
"time"
"myservice/internal/core/order/value_object"
)
type Order struct {
id value_object.OrderID
customerID value_object.CustomerID
items []OrderItem
status Status
total value_object.Money
confirmedAt *time.Time
}
func (o *Order) Confirm(paymentResult PaymentResult) error {
if o.status != StatusPending {
return &InvalidStatusTransitionError{From: o.status, To: StatusConfirmed}
}
if len(o.items) == 0 {
return &EmptyOrderError{OrderID: o.id}
}
if paymentResult.Amount.IsLessThan(o.total) {
return &InsufficientPaymentError{Required: o.total, Provided: paymentResult.Amount}
}
now := time.Now().UTC()
o.status = StatusConfirmed
o.confirmedAt = &now
return nil
}
Все инварианты заказа — в одном месте. Handler становится тонкой координацией:
// internal/core/order/usecase/confirm_order.go
package usecase
import (
"context"
"fmt"
"myservice/internal/core/order/port/out"
)
type ConfirmOrderCommand struct {
OrderID OrderID
PaymentRef string
}
type ConfirmOrderHandler struct {
orders out.OrderRepository
payments out.PaymentPort
}
func NewConfirmOrderHandler(orders out.OrderRepository, payments out.PaymentPort) *ConfirmOrderHandler {
return &ConfirmOrderHandler{orders: orders, payments: payments}
}
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd ConfirmOrderCommand) error {
order, err := h.orders.FindByID(ctx, cmd.OrderID)
if err != nil {
return fmt.Errorf("load order %s: %w", cmd.OrderID, err)
}
result, err := h.payments.Register(ctx, out.RegisterPaymentCommand{
OrderID: cmd.OrderID,
Amount: order.Total(),
})
if err != nil {
return fmt.Errorf("register payment: %w", err)
}
if err := order.Confirm(aggregate.PaymentResult{Amount: result.Amount}); err != nil {
return err
}
return h.orders.Save(ctx, order)
}
Handler не знает про HTTP, не знает про SQL — он работает с port-интерфейсами и вызывает методы агрегата.
Ошибки как значения
В Go ошибки — это значения, а не исключения. Domain-ошибки в core — типизированные структуры с категорией:
// internal/core/order/aggregate/errors.go
package aggregate
import "myservice/internal/core/apperr"
type InvalidStatusTransitionError struct {
From Status
To Status
}
func (e *InvalidStatusTransitionError) Error() string {
return "invalid status transition: " + string(e.From) + " → " + string(e.To)
}
func (e *InvalidStatusTransitionError) Kind() apperr.Kind { return apperr.Domain }
type EmptyOrderError struct{ OrderID OrderID }
func (e *EmptyOrderError) Error() string {
return "order " + string(e.OrderID) + " has no items"
}
func (e *EmptyOrderError) Kind() apperr.Kind { return apperr.Validation }
Ошибки port-интерфейсов объявляются в core/<bc>/port/out/, не в адаптере:
// internal/core/order/port/out/errors.go
package out
import "myservice/internal/core/apperr"
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 }
Handler ловит *out.PaymentPortError через errors.As — не привязываясь к конкретному адаптеру. HTTP-обработчик снаружи по apperr.Kind решает, вернуть 400 или 500.
Как передавать зависимости
В Go нет DI-аннотаций. Core экспортирует конструкторы; сборка всех зависимостей — только в bootstrap/main.go:
// Конструктор принимает зависимости через port-интерфейсы
func NewConfirmOrderHandler(orders out.OrderRepository, payments out.PaymentPort) *ConfirmOrderHandler {
return &ConfirmOrderHandler{orders: orders, payments: payments}
}
Что не нужно делать:
// ИЗБЕГАТЬ — глобальный синглтон
var globalOrderRepo *persistence.OrderRepository
func init() {
globalOrderRepo = persistence.NewOrderRepository(globalDB)
}
init() создаёт соединение с базой прямо в пакете, переопределить в тестах невозможно. Только конструкторы и явная сборка в main.go.
Типичные ошибки новичка
В core/ попал импорт инфраструктуры. Например, pgx или chi. Это сигнал, что бизнес-логика просочилась в адаптер или адаптерный код попал в core. Нужно переместить в правильный пакет.
Sqlc-generated тип как поле агрегата. db.Order — это persistence-деталь, она знает про структуру таблицы. Агрегат должен иметь свой тип aggregate.Order; маппинг между ними — в adapter/out/persistence/.
HTTP-DTO в core. CreateOrderRequest из HTTP-обработчика не должен попадать в usecase/. Вместо него — usecase.CreateOrderCommand с domain-типами; маппинг из DTO в команду делается в adapter/in/http/.
Логика в OrderService вместо агрегата. Проверки состояния заказа разлетятся по всему коду. Инварианты принадлежат агрегату.
Коротко
core/зависит только от стандартной библиотеки Go; никакого chi, pgx, kafka-go, slog.- В core входят: агрегаты, value objects, domain events, port-интерфейсы (
port/out/), use case handler'ы, domain services. - Бизнес-логика живёт внутри методов агрегата —
order.Confirm()инкапсулирует все инварианты. Логика в*Service-структурах — частая ошибка, которая приводит к дублированию. - Handler — тонкая координация: загрузить агрегат → вызвать domain-метод → сохранить.
- Domain-ошибки — типизированные структуры с категорией (
apperr.Kind), не строки. - Port-ошибки объявляются в
core/<bc>/port/out/, не в адаптере. - Зависимости передаются через конструкторы; сборка — только в
bootstrap/main.go.
Что почитать дальше
- Ports в Go — port-интерфейсы в
port/out/, port-ошибки,errors.Asв handler'е. - Адаптеры in в Go — chi-обработчик, маппер request → command.
- Адаптеры out в Go — реализация port-интерфейса, маппер domain ↔ persistence.
- Bootstrap и Composition Root — сборка зависимостей в
main.go, graceful shutdown. - Структура пакетов — раскладка пакетов, запрет кросс-адаптерных импортов.