← назад к разделу

Когда сервис разрастается, в нём начинает скапливаться запутанная смесь: 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.
  • Структура пакетов — раскладка пакетов, запрет кросс-адаптерных импортов.