Опирается на правила: R-HEX-CORE-1R-HEX-CORE-4 и R-HEX-CORE-X1R-HEX-CORE-X5раздел 3. Core слой.

Важно знать

  • core/<bc>/ зависит только от stdlib (context, errors, time, fmt) и внутренних пакетов core/apperr, core/<bc2>/port/out/.
  • Никаких chi, pgx, sqlc-generated types, kafka-go, slog, net/http — это infrastructure. Нарушение ловит архитектурный тест (R-HEX-TEST-1).
  • Содержит: агрегаты, value objects, domain events, port-интерфейсы (port/out/), use case handler'ы (usecase/), domain service'ы (service/).
  • Rich domain: бизнес-логика внутри aggregate-метода (order.Confirm()), не в *Service-структурах. Anemic domain — антипаттерн (R-HEX-CORE-X3).
  • Sqlc-generated struct (db.Order) в core/ как доменный тип — запрещён (R-HEX-CORE-X4). Это persistence-деталь.
  • HTTP-DTO (CreateOrderRequest) в core/ — запрещён (R-HEX-CORE-X5). REST-DTO живёт в adapter/in/http/.
  • DI-аннотаций в Go нет; core — чистые struct + interface. Wiring — только в bootstrap/main.go (R-HEX-CORE-3).

core/ — это сердце сервиса. Агрегаты, бизнес-правила, инварианты, события. Всё, что не зависит ни от какой инфраструктуры и работает без chi-роутера, без pgx-пула, без Kafka-продюсера. На практике сервис запускается на net/http + chi, но core этого не знает — он получает зависимости через port-интерфейсы, которые реализуют адаптеры.

Что в core/ разрешено

R-HEX-CORE-1 — точный список разрешённых зависимостей.

Разрешено:

  • Стандартная библиотека: context, errors, time, fmt, strings, math — без ограничений.
  • Пакет core/apperr — типизированные domain-ошибки (apperr.Kind, apperr.New).
  • Другие пакеты core/<bc2>/port/out/ — кросс-BC зависимость только через port, не прямой импорт aggregate.

Запрещено:

  • github.com/go-chi/chi — HTTP-маршрутизация.
  • github.com/jackc/pgx — PostgreSQL-драйвер.
  • github.com/redis/go-redis — Redis-клиент.
  • github.com/segmentio/kafka-go — Kafka.
  • log/slog — логирование. Логгер пробрасывается через context или middleware на edge, не в core.
  • Любые sqlc-generated типы (db.* из adapter/out/persistence/db/).
  • HTTP-DTO из adapter/in/http/.

Если в core/ появился такой import — файл лежит в неправильном месте или нарушена архитектура. Архитектурный тест в CI ловит это автоматически (см. Архитектурные тесты).

Структура core/

R-HEX-CORE-2 — типичная раскладка для bounded context order:

internal/
  core/
    apperr/
      kind.go              # apperr.Kind: Domain, Validation, Integration, NotFound
      errors.go            # apperr.New, apperr.Is
    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 и входящие в него Entity. Агрегат инкапсулирует инварианты.
  • value_object/ — неизменяемые типы (Money, OrderID, CustomerEmail). Сравниваются по значению, не по указателю.
  • port/out/ — интерфейсы: «что core нужно от внешнего мира». Репозитории, клиенты внешних систем, event publisher'ы.
  • usecase/ — Command + Handler пары. Handler координирует: загрузить агрегат → вызвать domain-метод → сохранить.
  • service/ — shared domain-логика, которая не помещается в один агрегат. Используется редко.

Rich domain — методы внутри агрегата

R-HEX-CORE-4 — бизнес-логика живёт в aggregate-методе, не в *Service-структуре.

// 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
}

func (o *Order) AddItem(product value_object.ProductID, qty int, price value_object.Money) error {
    if o.status != StatusDraft {
        return &InvalidStatusTransitionError{From: o.status, To: StatusDraft}
    }
    if qty <= 0 {
        return &InvalidQuantityError{Qty: qty}
    }
    o.items = append(o.items, OrderItem{ProductID: product, Qty: qty, Price: price})
    o.total = o.total.Add(price.Multiply(qty))
    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)
}

Что не так с anemic-вариантом:

// ИЗБЕГАТЬ — anemic: агрегат без логики, логика в 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-listener'е, в admin-handler'е
        return errors.New("no items")
    }
    order.Status = StatusConfirmed
    return s.orders.Save(ctx, order)
}

Проблемы anemic:

  • Инварианты разъезжаются. Логика Confirm повторяется в HTTP-handler'е, Kafka-consumer'е, admin-CLI — одна из копий отстанет.
  • Unit-тест на Order.Confirm невозможен — агрегат без логики. Все тесты тянут инфраструктуру.
  • Читаемость lifecycle падает: чтобы понять, когда заказ переходит в Confirmed, нужно обойти все Service-методы.

Ошибки в core/ — значения, не panic

В Go ошибки — значения. Domain-ошибки — типизированные структуры с apperr.Kind:

// 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, не специфичный *SberError. Подробнее — в статье про Ports.

DI в core/ — конструкторы, не глобальные синглтоны

R-HEX-CORE-3 — в Go нет DI-аннотаций. Core экспортирует конструкторы; wiring — только в bootstrap/main.go:

// ПРАВИЛЬНО: конструктор принимает зависимости через port-интерфейсы
func NewConfirmOrderHandler(orders out.OrderRepository, payments out.PaymentPort) *ConfirmOrderHandler {
    return &ConfirmOrderHandler{orders: orders, payments: payments}
}
// ИЗБЕГАТЬ: глобальный синглтон
var globalOrderRepo *persistence.OrderRepository  // нарушение R-HEX-CORE-X2

func init() {
    globalOrderRepo = persistence.NewOrderRepository(globalDB)  // init() — нет DI
}

init() создаёт соединение с базой прямо в пакете адаптера — переопределить в тестах невозможно. Только конструкторы; bootstrap/main.go создаёт все зависимости явно.

Что запрещено

АнтипаттернПравилоЧто взамен
import "github.com/go-chi/chi" в core/R-HEX-CORE-X1Убрать в adapter/in/http/; архитектурный тест ловит нарушение
import "github.com/jackc/pgx" в core/R-HEX-CORE-X2Persistence-деталь в adapter/out/persistence/; маппинг через <X>_mapper.go
Агрегат без методов, вся логика в OrderServiceR-HEX-CORE-X3Rich domain: order.Confirm() инкапсулирует инварианты
db.Order (sqlc-generated) как тип поля агрегатаR-HEX-CORE-X4Domain-struct aggregate.Order; db.Order — только в persistence/
CreateOrderRequest (HTTP-DTO) в core/R-HEX-CORE-X5usecase.CreateOrderCommand с domain-типами; маппинг — в adapter/in/http/
var db *pgx.Pool — глобальный синглтон в core/R-HEX-CORE-X2Pool — через конструктор адаптера, не глобально
init() создаёт DB-соединение в adapter/out/R-HEX-BOOT-X2Явное создание в bootstrap/main.go; тесты могут подменить адаптер
os.Exit в core/(Go-специфика)Exit только в bootstrap/main.go через signal.NotifyContext

Куда дальше

  • Ports — port-интерфейсы в core/<bc>/port/out/, port-ошибки, errors.As в handler'е.
  • Adapters in — chi-handler, маппер request → command, запрет репозитория напрямую.
  • Adapters out — реализация port-interface, compile-time assertion, маппер domain ↔ system-DTO.
  • Bootstrap / Composition Root — wiring в main.go, graceful shutdown, signal.NotifyContext.
  • Module Structure — раскладка пакетов, запрет кросс-adapter импортов, стрелка зависимостей.
  • Architecture Tests — packages.Load + forbidden-imports, CI required check.
  • When to Use Hexagonal — признаки «пора» и «рано» для Go-сервиса.