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

Когда в Go-сервисе нет чёткой структуры, OrderService запрашивает базу напрямую, вызывает внешнее API из одного метода и возвращает JSON из того же файла. Разобраться, где бизнес-логика, а где инфраструктура — невозможно. Тесты требуют поднятой базы, смена платёжной системы ломает половину кода.

Hexagonal Architecture решает это жёстким делением: всё, что составляет смысл сервиса, живёт в core/. Всё, что общается с внешним миром — HTTP, база, очереди — живёт в adapter/. Между ними — интерфейсы (порты), а собирается всё вместе только в bootstrap/.

Почему в Go это сложнее, чем в Java

В Java (Gradle, Maven) изоляция физическая: модуль core/ просто не имеет зависимости на модуль persistence/ в build.gradle. Класс из persistence буквально не компилируется в core.

В Go всё в одном модуле — go.mod один на весь сервис. Формально ничто не мешает написать в core/ import "github.com/jackc/pgx". Компилятор не остановит.

Поэтому в Go изоляция поддерживается двумя вещами: конвенцией импортов и автоматической проверкой в CI — архитектурным тестом, который не даст смержить PR с нарушением границы.

Раскладка папок

Типичный сервис с доменом 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               # чтение переменных окружения
    └── architecture_test.go   # проверка границ в CI

Минимальный стартовый набор: core/<bc>/, adapter/out/persistence/, один adapter/in/http/, bootstrap/. Дополнительные адаптеры добавляются по мере роста.

core/ — только стандартная библиотека

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() — живёт прямо в агрегате. Не в OrderService, не в handler'е.

В core/ нет func init() и нет глобальных переменных. Только чистые структуры и конструкторы. Всё соединяется в bootstrap/.

Каждая внешняя система — отдельный пакет

Частая ошибка — складывать все out-адаптеры в один пакет adapter/out/. Тогда pgx-зависимость «видит» Sber SDK, а замена одного эквайера затрагивает весь пакет.

Правило простое: одна внешняя система — один пакет.

adapter/out/
  persistence/      # pgx + sqlc — база данных
  sber/             # Sber Acquiring API
  odna_kassa/       # резервный эквайер
  redis/            # кэш / rate-limit
  kafka_producer/   # исходящие события

Что это даёт:

  • persistence/ не знает о существовании Sber SDK;
  • sber/ не видит sqlc-генерацию;
  • смена Sber API затрагивает только adapter/out/sber/, тесты остальных пакетов не ломаются;
  • каждый адаптер настраивает свой HTTP-клиент с собственными таймаутами и retry.

Каждый адаптер реализует interface-порт, объявленный в core/. Соответствие проверяется на этапе компиляции:

// 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 — компилятор сообщит немедленно.

Сам адаптер только маппит и вызывает внешнюю систему. Никаких бизнес-решений:

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. Что с ней делать — решает UseCase-хендлер в core/.

Разделение входящих адаптеров

По той же логике входящие адаптеры не смешиваются. User и admin — разные пакеты с разными middleware и разными правилами авторизации.

adapter/in/
  http/
    user/     # JWT от Keycloak, роутер для покупателей
    admin/    # отдельный middleware, другой audience
  kafka/      # consumer входящих событий

HTTP-handler принимает запрос, маппит в команду и передаёт UseCase-хендлеру. Никаких бизнес-правил внутри:

// internal/adapter/in/http/user/order_handler.go
package user

type OrderHandler struct {
    confirmOrder *usecase.ConfirmOrderHandler
}

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. Агрегат не сериализуется напрямую в HTTP-тело — возвращается отдельный response-тип:

// internal/adapter/in/http/user/order_request_mapper.go
package user

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()),
    }
}

bootstrap/ — единственное место сборки

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 — стандартный способ корректного завершения в Go 1.21+. os.Exit в core/ или адаптерах не используется.
  • Каждый NewXxx получает зависимости явно через параметры — никаких func init() и глобальных синглтонов.
  • В bootstrap/ нет бизнес-логики: никаких if cfg.Feature { доменное правило }, никаких chi-handler'ов.

Это позволяет в тестах подменить любой адаптер на in-memory заглушку без магии.

Стрелка зависимостей и архитектурный тест

Зависимости текут строго в одну сторону:

bootstrap → adapter/in/* → core
bootstrap → adapter/out/* → core
  • core/<bc>/ не импортирует ни одного адаптера.
  • adapter/in/http/user/ импортирует core/order/usecase — и ничего из adapter/out/.
  • adapter/out/sber/ импортирует core/order/port/out — и ничего из adapter/out/persistence/.

В Go нарушение этой стрелки не даёт ошибки компиляции — компилятор не запрещает такие импорты. Поэтому проверка автоматизирована тестом:

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

Запускается в CI командой go test -tags arch ./bootstrap/... как обязательная проверка — PR не проходит при падении.

Частые ошибки

core/ импортирует chi или pgx. Решение: только stdlib и core/apperr в доменном коде. Нарушение поймает архитектурный тест.

Все out-адаптеры в одном пакете. Это означает, что зависимости перемешиваются. Правило: отдельный пакет на каждую внешнюю систему — adapter/out/sber/, adapter/out/persistence/.

sqlc-генерированный тип как доменный тип в core/. В core/ должен быть aggregate.Order, а маппинг — в persistence/<bc>_mapper.go.

Один out-адаптер импортирует другой. Координация двух адаптеров — задача UseCase-хендлера в core/, который получает оба порта через конструктор.

User и admin роутеры в одном пакете. У них разные middleware и разная авторизация — это разные пакеты: adapter/in/http/user/ и adapter/in/http/admin/.

func init() создаёт соединение с базой. Всё соединение и wiring — только в bootstrap/main.go через явные конструкторы.

Коротко

  • core/<bc>/ — только stdlib и apperr. Никакого chi, pgx, slog.
  • Одна внешняя система — один пакет в adapter/out/. Изоляция зависимостей и настроек.
  • Входящие адаптеры разделяются по назначению: user/, admin/, kafka/ — разными пакетами.
  • bootstrap/main.go — единственное место, где всё собирается. Никаких func init(), никаких глобальных переменных.
  • Зависимости текут в одну сторону: bootstrap → adapter/* → core.
  • Граница не физическая (в Go нет Gradle-модулей) — её охраняет архитектурный тест в CI.

Что почитать дальше

  • Core слой — агрегаты, VO, domain events, ошибки-значения в core/.
  • Ports — outbound-interface в core/, port-ошибки, compile-time assertion адаптера.
  • Adapters in — chi-handler, маппер request-DTO → command, httperr.Write.
  • Adapters out — реализация порт-interface, маппер domain ↔ system-DTO.
  • Bootstrap / composition root — graceful shutdown, wire-up без init().
  • Архитектурные тесты — packages.Load, forbidden-imports, CI required check.