← Back to the section

When a Go service has no clear structure, OrderService queries the database directly, calls an external API from a single method, and returns JSON from the same file. Figuring out where the business logic lives and where the infrastructure is becomes impossible. Tests require a running database, and switching payment systems breaks half the code.

Hexagonal Architecture solves this with a strict split: everything that makes up the meaning of the service lives in core/. Everything that talks to the outside world — HTTP, database, queues — lives in adapter/. Between them are interfaces (ports), and it all gets wired together only in bootstrap/.

Why this is harder in Go than in Java

In Java (Gradle, Maven) the isolation is physical: the core/ module simply has no dependency on the persistence/ module in build.gradle. A class from persistence literally does not compile in core.

In Go everything lives in one module — there is a single go.mod for the whole service. Formally, nothing stops you from writing import "github.com/jackc/pgx" in core/. The compiler will not stop you.

That is why in Go the isolation is upheld by two things: an import convention and an automated CI check — an architecture test that will not let a PR violating the boundary be merged.

Folder layout

A typical service with an order domain:

<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               # reading environment variables
    └── architecture_test.go   # boundary check in CI

The minimal starter set: core/<bc>/, adapter/out/persistence/, one adapter/in/http/, bootstrap/. Additional adapters are added as things grow.

core/ — standard library only

internal/core/<bc>/ imports exclusively stdlib and core/apperr. No github.com/go-chi/chi, no github.com/jackc/pgx, no github.com/segmentio/kafka-go.

This is the key rule: domain code must not know which framework returns the response or where exactly the data is written.

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

The business rule — Order.Confirm() — lives right in the aggregate. Not in OrderService, not in a handler.

In core/ there is no func init() and no global variables. Only pure structs and constructors. Everything is wired together in bootstrap/.

Each external system — its own package

A common mistake is to dump all out-adapters into a single adapter/out/ package. Then the pgx dependency "sees" the Sber SDK, and replacing one acquirer affects the whole package.

The rule is simple: one external system — one package.

adapter/out/
  persistence/      # pgx + sqlc — database
  sber/             # Sber Acquiring API
  odna_kassa/       # backup acquirer
  redis/            # cache / rate-limit
  kafka_producer/   # outgoing events

What this gives you:

  • persistence/ knows nothing about the existence of the Sber SDK;
  • sber/ does not see the sqlc generation;
  • a change in the Sber API affects only adapter/out/sber/, and the tests of the other packages do not break;
  • each adapter configures its own HTTP client with its own timeouts and retries.

Each adapter implements an interface port declared in core/. Conformance is checked at compile time:

// adapter/out/sber/payment_adapter.go
package sber

import "github.com/<org>/<svc>/internal/core/order/port/out"

var _ out.PaymentPort = (*PaymentAdapter)(nil)

If PaymentAdapter stops satisfying PaymentPort — the compiler reports it immediately.

The adapter itself only maps and calls the external system. No business decisions:

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
}

Handling the Sber error means wrapping it in SberError. What to do with it is decided by the UseCase handler in core/.

Splitting inbound adapters

By the same logic, inbound adapters are not mixed together. User and admin are separate packages with different middleware and different authorization rules.

adapter/in/
  http/
    user/     # JWT from Keycloak, router for buyers
    admin/    # separate middleware, different audience
  kafka/      # consumer of incoming events

An HTTP handler accepts a request, maps it into a command, and hands it to the UseCase handler. No business rules inside:

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

The mapper is a separate struct, not embedded in the handler. The aggregate is not serialized directly into the HTTP body — a separate response type is returned:

// 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/ — the single wiring point

bootstrap/main.go is the only file that imports all the adapters together. This is where dependencies are created, the router is assembled, and the server is started.

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

A few important details:

  • signal.NotifyContext is the standard way to shut down gracefully in Go 1.21+. os.Exit is not used in core/ or in adapters.
  • Every NewXxx receives its dependencies explicitly through parameters — no func init() and no global singletons.
  • There is no business logic in bootstrap/: no if cfg.Feature { domain rule }, no chi handlers.

This lets you swap any adapter for an in-memory stub in tests without magic.

The dependency arrow and the architecture test

Dependencies flow strictly in one direction:

bootstrap → adapter/in/* → core
bootstrap → adapter/out/* → core
  • core/<bc>/ imports not a single adapter.
  • adapter/in/http/user/ imports core/order/usecase — and nothing from adapter/out/.
  • adapter/out/sber/ imports core/order/port/out — and nothing from adapter/out/persistence/.

In Go, violating this arrow does not produce a compile error — the compiler does not forbid such imports. That is why the check is automated with a test:

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

It runs in CI via go test -tags arch ./bootstrap/... as a mandatory check — the PR does not pass if it fails.

Common mistakes

core/ imports chi or pgx. Fix: only stdlib and core/apperr in domain code. The architecture test will catch the violation.

All out-adapters in one package. This means the dependencies get intertwined. Rule: a separate package for each external system — adapter/out/sber/, adapter/out/persistence/.

An sqlc-generated type used as a domain type in core/. In core/ there should be aggregate.Order, and the mapping goes in persistence/<bc>_mapper.go.

One out-adapter imports another. Coordinating two adapters is the job of the UseCase handler in core/, which receives both ports through its constructor.

User and admin routers in one package. They have different middleware and different authorization — these are separate packages: adapter/in/http/user/ and adapter/in/http/admin/.

func init() creates a database connection. All connecting and wiring happens only in bootstrap/main.go through explicit constructors.

In short

  • core/<bc>/ — only stdlib and apperr. No chi, pgx, slog.
  • One external system — one package in adapter/out/. Isolation of dependencies and settings.
  • Inbound adapters are split by purpose: user/, admin/, kafka/ — as separate packages.
  • bootstrap/main.go is the single place where everything is wired together. No func init(), no global variables.
  • Dependencies flow in one direction: bootstrap → adapter/* → core.
  • The boundary is not physical (Go has no Gradle modules) — it is guarded by the architecture test in CI.

Further reading

  • Core layer — aggregates, VOs, domain events, error values in core/.
  • Ports — outbound interface in core/, port errors, compile-time adapter assertion.
  • Adapters in — chi handler, request-DTO → command mapper, httperr.Write.
  • Adapters out — port-interface implementation, domain ↔ system-DTO mapper.
  • Bootstrap / composition root — graceful shutdown, wire-up without init().
  • Architecture tests — packages.Load, forbidden imports, CI required check.