Опирается на правила: R-HEX-MOD-1R-HEX-MOD-5 и R-HEX-MOD-X1R-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, slogR-HEX-CORE-X1Только stdlib + core/apperr; enforcement — архитектурный тест
sqlc-generated db.Order как доменный тип в core/R-HEX-CORE-X4aggregate.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-X3adapter/in/http/user/ и adapter/in/http/admin/ с раздельными middleware
var db *pgx.Pool глобальная переменная в core/R-HEX-CORE-X2Pool передаётся в конструктор persistence.NewOrderRepository(db)
func init() создаёт соединение с базой в адаптереR-HEX-BOOT-X2Явный конструктор; wiring — только в bootstrap/main.go
os.Exit в core/ или адаптереantipatternsignal.NotifyContext + srv.Shutdown только в bootstrap/
Бизнес-логика в adapter/in/http/ handlerR-HEX-AIN-X1Order.Confirm() или UseCase-хендлер в core/
adapter/in/http/ инжектит persistence.OrderRepositoryR-HEX-AIN-X2Handler знает только 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-сервисов.