Когда в 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.