Опирается на правила:
R-HEX-CORE-1…R-HEX-CORE-4иR-HEX-CORE-X1…R-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-X2 | Persistence-деталь в adapter/out/persistence/; маппинг через <X>_mapper.go |
Агрегат без методов, вся логика в OrderService | R-HEX-CORE-X3 | Rich domain: order.Confirm() инкапсулирует инварианты |
db.Order (sqlc-generated) как тип поля агрегата | R-HEX-CORE-X4 | Domain-struct aggregate.Order; db.Order — только в persistence/ |
CreateOrderRequest (HTTP-DTO) в core/ | R-HEX-CORE-X5 | usecase.CreateOrderCommand с domain-типами; маппинг — в adapter/in/http/ |
var db *pgx.Pool — глобальный синглтон в core/ | R-HEX-CORE-X2 | Pool — через конструктор адаптера, не глобально |
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-сервиса.