Опирается на правила:
R-HEX-MOD-1…R-HEX-MOD-5иR-HEX-MOD-X1…R-HEX-MOD-X3из Hexagonal Rules → раздел 2. Структура модулей.
Важно знать
- В Go нет Gradle-модулей: изоляция — конвенция импортов, проверяемая архитектурным тестом в CI.
internal/core/<bc>/зависит только от stdlib (context,errors,time) иcore/apperr. Никакихchi,pgx,slog.- Каждый out-adapter — отдельный пакет (
adapter/out/sber/,adapter/out/persistence/). Не класть разные системы в один пакет.- Каждый тип входа — отдельный пакет (
adapter/in/http/user/,adapter/in/http/admin/,adapter/in/kafka/).bootstrap/main.go— единственное место, где импортируются все адаптеры вместе. Никто не зависит отbootstrap/.- Стрелка зависимостей строго:
bootstrap → adapter/* → core. Адаптеры не импортируют друг друга.- Граница между пакетами — не compile-error (как в Java), а архитектурный тест
packages.Loadв CI: PR не мерджится при падении.
Hexagonal в Java обеспечивается Gradle-модулями: класс из core/ физически не видит классы из persistence/, потому что нет зависимости в build.gradle. В Go одного репо такой барьер не существует — всё в одном GOPATH, импортировать можно что угодно. Поэтому enforcement переезжает с compile-time на архитектурный тест: go test ./bootstrap/... с тегом arch проверяет, что core/ не завозит инфраструктуру. Принцип тот же, механизм другой.
Раскладка пакетов
R-HEX-MOD-1 / R-HEX-MOD-2 — типичный сервис с доменом order:
<service>/
├── internal/
│ ├── core/
│ │ └── order/
│ │ ├── aggregate/ order.go
│ │ ├── value_object/ money.go
│ │ ├── event/ order_confirmed.go
│ │ ├── port/out/ payment_port.go, order_repo.go, errors.go
│ │ └── usecase/ confirm_order.go
│ ├── adapter/
│ │ ├── in/
│ │ │ ├── http/
│ │ │ │ ├── user/ order_handler.go, order_request_mapper.go
│ │ │ │ └── admin/ admin_order_handler.go
│ │ │ └── kafka/ order_events_consumer.go
│ │ └── out/
│ │ ├── persistence/ order_repository.go, order_mapper.go
│ │ ├── sber/ payment_adapter.go, payment_mapper.go, errors.go
│ │ └── odna_kassa/ refund_adapter.go, refund_mapper.go
│ └── apperr/ kind.go, errors.go
└── bootstrap/
├── main.go # composition root
├── config.go # envconfig
└── architecture_test.go # R-HEX-TEST-1
Минимальный набор для Уровня 3 — core/<bc>/, adapter/out/persistence/, один adapter/in/http/, bootstrap/. Дополнительные адаптеры добавляются по мере роста сервиса.
core/ — только stdlib и apperr
R-HEX-CORE-1: internal/core/<bc>/ импортирует исключительно stdlib и core/apperr. Никаких github.com/go-chi/chi, github.com/jackc/pgx, github.com/segmentio/kafka-go.
// internal/core/order/aggregate/order.go
package aggregate
import (
"errors"
"time"
"github.com/<org>/<svc>/internal/core/order/value_object"
"github.com/<org>/<svc>/internal/apperr"
)
type Order struct {
id OrderID
customerID CustomerID
items []OrderItem
status Status
total value_object.Money
}
func (o *Order) Confirm(paymentID PaymentID, confirmedAt time.Time) error {
if o.status != StatusPending {
return &InvalidStatusTransitionError{From: o.status, To: StatusConfirmed}
}
o.status = StatusConfirmed
return nil
}
type InvalidStatusTransitionError struct {
From, 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 }
Богатый агрегат: бизнес-правила живут в order.Confirm, не в *Service-структуре. Это R-HEX-CORE-4 — rich domain. Анемичная модель (только поля + геттеры, логика в сервисе) — нарушение R-HEX-CORE-X3.
DI-аннотаций в Go нет. В core/ нет func init(), нет глобальных переменных. Только чистые структуры и конструкторы — wiring исключительно в bootstrap/.
Per-system out-адаптеры
R-HEX-MOD-3: каждая внешняя система — отдельный пакет в adapter/out/.
adapter/out/
persistence/ # pgx + sqlc — PG как система хранения
sber/ # Sber Acquiring API
odna_kassa/ # ОднаКасса — резервный эквайер
redis/ # кэш / rate-limit state
kafka_producer/ # исходящие события (если не persistence-level outbox)
Зачем разделять:
- Изоляция зависимостей.
persistence/не знает, что существует Sber SDK.sber/не видит sqlc-генерацию. Если Sber меняет API — меняем толькоadapter/out/sber/, тесты остальных пакетов не затрагиваются. - Resilience per-system.
sber/Clientнастраиваетnet/http.Clientс таймаутами, retry и circuit-breaker для Sber.odna_kassa/Client— своим набором параметров. Один общий HTTP-клиент для всего исходящего трафика — антипаттерн. - Compile-time assertion порта. В каждом адаптере:
// adapter/out/sber/payment_adapter.go
package sber
import "github.com/<org>/<svc>/internal/core/order/port/out"
var _ out.PaymentPort = (*PaymentAdapter)(nil)
Если PaymentAdapter перестаёт реализовывать PaymentPort — ошибка компиляции, не тест.
Адаптер реализует порт-interface, объявленный в core/, и только его:
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
}
Адаптер мапит, не решает. Интерпретация кода ответа Sber — обёртка в SberError; handler в core/ ловит через errors.As базовый *out.PaymentPortError и принимает решение. Это R-HEX-AOUT-X2.
Per-purpose in-адаптеры
R-HEX-MOD-4: каждый тип входа — отдельный пакет. Смешивать user и admin в одном adapter/in/http/ без разделения — нарушение R-HEX-MOD-X3.
adapter/in/
http/
user/ # chi-роутер для конечного покупателя — JWT от Keycloak
admin/ # chi-роутер для операторов — отдельный middleware + другой audience
kafka/ # consumer-loop для входящих событий (если есть)
HTTP-handler маппит request-DTO в команду и передаёт её UseCase-хендлеру:
// internal/adapter/in/http/user/order_handler.go
package user
import (
"encoding/json"
"net/http"
"github.com/<org>/<svc>/internal/core/order/usecase"
"github.com/<org>/<svc>/internal/apperr"
"github.com/<org>/<svc>/internal/adapter/in/http/httperr"
)
type OrderHandler struct {
confirmOrder *usecase.ConfirmOrderHandler
}
func NewOrderHandler(confirmOrder *usecase.ConfirmOrderHandler) *OrderHandler {
return &OrderHandler{confirmOrder: confirmOrder}
}
func (h *OrderHandler) ConfirmOrder(w http.ResponseWriter, r *http.Request) {
var req ConfirmOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperr.Write(w, r, apperr.NewValidation("invalid json"))
return
}
cmd := OrderRequestMapper{}.ToConfirmCommand(req)
if err := h.confirmOrder.Handle(r.Context(), cmd); err != nil {
httperr.Write(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
Бизнес-правила в handler запрещены (R-HEX-AIN-X1). Handler не инжектит persistence.OrderRepository напрямую (R-HEX-AIN-X2) — только UseCase-хендлер.
Маппер — отдельная структура, не встроенная в handler:
// internal/adapter/in/http/user/order_request_mapper.go
package user
import (
"github.com/<org>/<svc>/internal/core/order/aggregate"
"github.com/<org>/<svc>/internal/core/order/usecase"
)
type OrderRequestMapper struct{}
func (OrderRequestMapper) ToConfirmCommand(req ConfirmOrderRequest) usecase.ConfirmOrderCommand {
return usecase.ConfirmOrderCommand{
OrderID: aggregate.OrderID(req.OrderID),
PaymentRef: req.PaymentRef,
}
}
func (OrderRequestMapper) ToOrderResponse(o *aggregate.Order) OrderResponse {
return OrderResponse{
ID: string(o.ID()),
Status: string(o.Status()),
}
}
aggregate.Order не сериализуется напрямую в HTTP-тело — возвращается OrderResponse из маппера (R-HEX-AIN-X3).
bootstrap/ — composition root
R-HEX-MOD-5: bootstrap/main.go — единственное место, где все адаптеры собираются вместе.
// bootstrap/main.go
package main
import (
"context"
"log/slog"
"net/http"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/<org>/<svc>/internal/adapter/out/persistence"
"github.com/<org>/<svc>/internal/adapter/out/sber"
httpuser "github.com/<org>/<svc>/internal/adapter/in/http/user"
"github.com/<org>/<svc>/internal/core/order/usecase"
)
func main() {
cfg := mustLoadConfig()
logger := slog.Default()
db := mustOpenDB(cfg.DBURL)
orderRepo := persistence.NewOrderRepository(db)
sberClient := sber.NewClient(cfg.SberURL, cfg.SberKey)
paymentAdapter := sber.NewPaymentAdapter(sberClient)
confirmHandler := usecase.NewConfirmOrderHandler(orderRepo, paymentAdapter)
r := chi.NewRouter()
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
orderHTTP := httpuser.NewOrderHandler(confirmHandler)
r.Post("/orders/{id}/confirm", orderHTTP.ConfirmOrder)
srv := &http.Server{
Addr: cfg.Addr,
Handler: r,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
logger.Info("server starting", "addr", cfg.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server error", "err", err)
}
}()
<-ctx.Done()
stop()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("graceful shutdown failed", "err", err)
}
logger.Info("server stopped")
}
signal.NotifyContext — стандартный способ graceful shutdown в Go 1.21+. os.Exit в core/ или в адаптерах запрещён — только через штатный сигнальный путь в bootstrap/.
Конструкторы, не init() и не глобальные синглтоны. Каждый NewXxx получает зависимости явно. Это позволяет в тестах подменить любой адаптер на in-memory заглушку без магии.
Бизнес-логики в bootstrap/ нет. Никаких chi-handler'ов, никаких if cfg.Feature { доменное правило } (R-HEX-BOOT-X1). Создание роутера или wiring UseCase-хендлеров в adapter/in/http/ — нарушение R-HEX-BOOT-X2.
Стрелка зависимостей
bootstrap → adapter/in/* → core
bootstrap → adapter/out/* → core
bootstrap/main.goимпортирует все адаптеры.adapter/in/http/user/импортируетcore/order/usecase— и ничего изadapter/out/.adapter/out/sber/импортируетcore/order/port/out— и ничего изadapter/out/persistence/.core/<bc>/не импортирует ни одного адаптера.
Нарушение стрелки в Go не даст compile-error — Go не запрещает циклические зависимости между пакетами одного модуля на уровне сборки (только цикл пакет→сам→себя). Поэтому guard — архитектурный тест:
// bootstrap/architecture_test.go
//go:build arch
package main_test
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/tools/go/packages"
)
func TestCoreHasNoInfraImports(t *testing.T) {
forbidden := []string{
"github.com/go-chi/chi",
"github.com/jackc/pgx",
"github.com/segmentio/kafka-go",
"github.com/redis/go-redis",
"log/slog",
}
cfg := &packages.Config{Mode: packages.NeedImports | packages.NeedName}
pkgs, err := packages.Load(cfg, "./internal/core/...")
require.NoError(t, err)
for _, pkg := range pkgs {
for _, imp := range pkg.Imports {
for _, fb := range forbidden {
if strings.HasPrefix(imp, fb) {
t.Errorf("core package %s imports forbidden %s", pkg.PkgPath, imp)
}
}
}
}
}
func TestInAdapterDoesNotImportOutAdapter(t *testing.T) {
cfg := &packages.Config{Mode: packages.NeedImports | packages.NeedName}
pkgs, err := packages.Load(cfg, "./internal/adapter/in/...")
require.NoError(t, err)
for _, pkg := range pkgs {
for _, imp := range pkg.Imports {
if strings.Contains(imp, "/internal/adapter/out/") {
t.Errorf("in-adapter %s imports out-adapter %s", pkg.PkgPath, imp)
}
}
}
}
go test -tags arch ./bootstrap/... запускается в CI как required check — PR не мерджится при падении (R-HEX-TEST-2).
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
core/<bc>/ импортирует chi, pgx, slog | R-HEX-CORE-X1 | Только stdlib + core/apperr; enforcement — архитектурный тест |
sqlc-generated db.Order как доменный тип в core/ | R-HEX-CORE-X4 | aggregate.Order в core, маппинг в persistence/<bc>_mapper.go |
Все out-адаптеры в одном пакете adapter/out/ | R-HEX-MOD-X1 | Отдельный пакет на каждую систему: adapter/out/sber/, adapter/out/persistence/ |
adapter/out/persistence/ импортирует adapter/out/sber/ | R-HEX-MOD-X2 | Координация двух адаптеров — UseCase-хендлер в core/, который инжектит оба порта |
User и admin роутеры в одном пакете adapter/in/http/ | R-HEX-MOD-X3 | adapter/in/http/user/ и adapter/in/http/admin/ с раздельными middleware |
var db *pgx.Pool глобальная переменная в core/ | R-HEX-CORE-X2 | Pool передаётся в конструктор persistence.NewOrderRepository(db) |
func init() создаёт соединение с базой в адаптере | R-HEX-BOOT-X2 | Явный конструктор; wiring — только в bootstrap/main.go |
os.Exit в core/ или адаптере | antipattern | signal.NotifyContext + srv.Shutdown только в bootstrap/ |
Бизнес-логика в adapter/in/http/ handler | R-HEX-AIN-X1 | Order.Confirm() или UseCase-хендлер в core/ |
adapter/in/http/ инжектит persistence.OrderRepository | R-HEX-AIN-X2 | Handler знает только UseCase-хендлер из core/usecase/ |
Куда дальше
- Core слой — агрегаты, VO, domain events, ошибки-значения в
core/<bc>/. - Ports — outbound-interface в
core/<bc>/port/out/, port-ошибки, compile-time assertion адаптера. - Adapters in — chi-handler, маппер request-DTO → command,
httperr.Writeна edge. - Adapters out — реализация порт-interface, маппер domain ↔ system-DTO, per-system isolation.
- Bootstrap / composition root — graceful shutdown с
signal.NotifyContext, wire-up безinit(). - Архитектурные тесты —
packages.Load, forbidden-imports, CI required check. - Когда применять Hexagonal — признаки «пора» и «рано» для Go-сервисов.