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.NotifyContextis the standard way to shut down gracefully in Go 1.21+.os.Exitis not used in core/ or in adapters.- Every
NewXxxreceives its dependencies explicitly through parameters — nofunc init()and no global singletons. - There is no business logic in
bootstrap/: noif 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/importscore/order/usecase— and nothing fromadapter/out/.adapter/out/sber/importscore/order/port/out— and nothing fromadapter/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.gois the single place where everything is wired together. Nofunc 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.