rgoussu@goussu: ~/library/go
~/library/go cat hexagonal-reference-implementation.md

Hexagonal architecture reference implementation — Go

# The house hexagonal reference realized for Go — one Go module per service, a modulith of facade-fronted context packages under internal/modules/, cmd/ binaries as assemblies, ports at the consumer, nested internal/ walls that make the gateway rule a compile error, and explicit constructor wiring with wire as the sanctioned escalation.

Conceptsaved 2026-08-09updated 2026-08-14 #hexagonal-architecture#architecture#go#packages#modulith#reference-implementation

Overview

The house reference implementation of hexagonal architecture for Go, standing on its own terms. The shared principles live in that note — trisection, one deployment unit per delivery typology, a contract face and an implementation face for the domain, containerisation beside its unit, README.md/AGENTS.md at every level. Go's realization builds the walls from what the language gives: a service is one Go module, packages don't declare dependency rules, so nested internal/ visibility holds the wall that matters most and an import linter holds the softer rules. Settled as the house reference (2026-08-09 discussion); re-homed onto the modulith layout 2026-08-14, where Go turns out to have the least ceremony of the four references — a bounded context is a directory with a facade package and an internal/ beneath it. Compile-verified against a hand-built two-context skeleton on Go 1.24.7 (2026-08-14): two claims did not survive contact with the compiler — where driven adapters may live, and what the facade may re-export — and both are corrected below.

Key points

  • One Go module per service; one cmd/<typology>/ per deployment unit. Each assembly gets a cmd/ binary — cmd/api/, cmd/consumer/, cmd/cron/ — with its Dockerfile beside its main.go. Multi-module workspaces inside one service buy a hard module graph at a real toolchain cost; not the default.
  • A bounded context is internal/modules/<context>/, fronted by a facade package. The directory's root package is the module's entire exported surface: New(Deps) *Module, (*Module).Service() for peers, (*Module).MountHTTP(mux) for the assembly — and nothing else. Everything else sits under internal/modules/<context>/internal/, unimportable from anywhere else in the repository — the compiler, not a linter, keeps contexts apart. This is the cheapest realization of the modulith across the four references: a context is a directory with a facade and an internal/ beneath it, and it costs zero build files.
  • The facade re-exports nothing, and that is what makes the gateway rule a compile error (settled 2026-08-14). The tempting move is a type alias per port (type OrderLookup = domain.OrderLookup) plus aliases for the types in its signature, so Deps can name them. Don't: a probe showed that once CustomerRef and Activity are aliased, any package in the repository compiles an implementation of billing's port. Alias nothing — give Deps fields the internal type directly — and the same probe fails with want ActivityFor(context.Context, domain.CustomerRef) (domain.Activity, error), a signature unnameable outside billing's own directory. Only packages inside the consumer's directory can implement the consumer's ports, and the compiler says so. The assembly loses nothing: := infers the unnameable type, so main still constructs, holds and passes the value; it simply cannot write the type's name. go doc on a pure-consumer facade then prints exactly three symbols — Deps, Module, New — and on a provider, those plus Service and its DTOs. That listing is the context's contract, and it is now impossible to widen by accident.
  • Inside a context, the trisection is package layout. Under its internal/: domain/ (the contract face — ports, commands, events, factories), the core face nested one level deeper under domain/internal/…, plus userside/… (handlers, mappers). Driven adapters sit beside that internal/, not inside it — see the next bullet. The two nested internal/ levels do two different jobs — the outer hides the context from its peers, the inner hides the core from the context's own adapters.
  • The domain is one package tree with two faces. Go defines interfaces at the consumer, and for driven ports the consumer is the domain, so ports live in the domain package by design: the contract face is …/internal/domain/ (ports, commands, events, factories), the implementation face is …/internal/domain/internal/… — package layout, not separate artifacts.
  • The core stays compiler-hidden via nested internal/. internal/ visibility is directory-scoped, so packages under …/domain/internal/… are importable only from within the domain subtree. Ports, commands, events and exported factory functions live in …/internal/domain/; entities and domain services live beneath it. Not even the context's own facade can import the core — it calls domain.NewPlaceOrder(repo, billing) and receives the driving-port interface. That is the Maven wall, rebuilt from a visibility rule and the constructor-returns-interface idiom.
  • Primary adapters live in …/internal/userside/<adapter>/ (handlers, mappers, route registration — never a server bootstrap); their transport contracts (generated OpenAPI types, protobuf stubs, message schemas) live in api/<context>/ at the root when they're published to other teams, necessarily outside internal/ since publication is the point. The contract artifact exists only where the contract is real (nothing for cron). Give api/ its own tiny go.mod when outside consumers exist, so they don't drag the service's dependency tree.
  • Secondary adapters live at internal/modules/<ctx>/infra/<technology>/ — outside the context's internal/, inside its directory (corrected 2026-08-14). The obvious placement, under the context's internal/, does not compile: cmd/api/main.go cannot construct what it cannot import (use of internal package … not allowed), and the assembly wiring its own driven adapters is the whole point. Sitting one level out works because Go's internal/ rule admits anything rooted at the parent of internal/ — so …/ordering/infra/postgres still imports …/ordering/internal/domain legally, while cmd/ can name it. Granularity is the same rule as Java: the assembly's imports (and thus the linker) decide what ships in each binary; Go's dead-code elimination means an unimported adapter costs the binary nothing.
    • The residue is the layout's one genuine linter rule. Adapters outside the context's internal/ are importable by peers too — verified: a probe package compiled memory.NewRepo() against another context's adapter. How much that leaks depends on the adapter's own signatures: methods taking the context's internal domain types are unusable outside it, but any method over primitives is callable. So depguard must forbid modules/<a>/… → modules/<b>/infra/…, and this is the one rule the Go layout genuinely cannot get from the compiler. The two rejected alternatives are worse: letting the facade re-export adapter constructors drags every driver's init() into every binary that imports the facade, and having the module pick its own adapters from config moves adapter selection out of the assembly entirely.
  • Peer modules meet in internal/modules/<consumer>/gateway/<peer>/ — the only package allowed to import another module's facade, and the Go home of Java's infra/<peer>-gateway. It sits outside the consumer's internal/ so the assembly can construct it, and inside the consumer's directory so it can still see the consumer's own ports. One grep over gateway/ lists every inter-context edge.
  • The assembly point is each main.go. Explicit constructor wiring, no DI framework: construct each module, hand a peer's Service() to the gateway that wants it, mount the user-side adapters on the server. Shared assembly helpers can live in internal/config/ when several binaries wire identical subsets.
  • Dependency injection is a pattern here, not a container — see the Details section for the settled stance: constructor injection at the composition root by default, wire as the sanctioned escalation, reflection containers (fx/dig) ruled out.
  • Command dispatch: no mediator object (settled 2026-08-09) — commands stay structs, the driving port stays a per-use-case interface wired explicitly in main, and cross-cutting concerns are decorator functions around the ports. The ruling and the rejected registry form are in the Details.
  • The linter covers only the softer rules. With contexts and cores both compiler-hidden, what's left for depguard/go-arch-lint in CI: domain imports nothing from userside/infra, infra never imports userside, adapters never import each other, and only gateway/… packages import a peer module. A go.work multi-module split to harden these too was considered and rejected: the toolchain friction (inter-module versioning, IDE, release tagging) outweighs walls the linter holds fine.

Details

The derived tree

acme-service/
├── README.md / AGENTS.md
├── go.mod
├── cmd/
│   ├── api/                        # main.go + Dockerfile  ← deployment unit
│   ├── consumer/
│   └── cron/
├── api/                            # published contracts, per context
│   └── ordering/                   #   openapi/, proto/, schemas/
├── internal/
│   ├── platform/
│   │   ├── kernel/                 # Command/Query types, decorators — no deps
│   │   └── clock/                  # ubiquitous ports + adapters
│   ├── modules/
│   │   ├── ordering/               # ← the facade package: the whole exported surface
│   │   │   ├── ordering.go         #   New(Deps), (*Module).Service/MountHTTP — no aliases
│   │   │   ├── infra/              #   driven adapters, OUTSIDE the context's internal/
│   │   │   │   ├── postgres/       #     so cmd/ can construct them (see the walls table)
│   │   │   │   └── kafka/
│   │   │   └── internal/           #   invisible to every other module
│   │   │       ├── domain/         #     ports, commands, events, factories
│   │   │       │   └── internal/
│   │   │       │       └── order/  #       entities, services — hidden core
│   │   │       └── userside/
│   │   │           ├── http/       #     handlers + DTO↔command mapping
│   │   │           └── consumer/
│   │   └── billing/
│   │       ├── billing.go
│   │       ├── infra/
│   │       ├── gateway/
│   │       │   └── ordering/       # the one package importing a peer facade
│   │       └── internal/           # same shape as ordering
│   └── config/                     # optional shared wiring helpers
├── compose.yaml
└── deployment/                     # IaC: OpenTofu modules + per-environment stacks

Note what the facade file buys: cmd/api/main.go imports internal/modules/ordering and internal/modules/billing and nothing else from either context. Every other package in the repository is behind an internal/ the compiler refuses to cross.

The two visibility walls that replace the Maven graph:

flowchart TB
    subgraph unit["cmd/api (deployment unit)"]
        MAIN["main.go (the assembly point)"]
    end
    subgraph ordering["internal/modules/ordering"]
        FACADE["ordering.go — New, Service, MountHTTP"]
        subgraph oint["…/internal (hidden from every peer)"]
            PORTS["domain: ports, commands, factories"]
            USERSIDE["userside/http"]
            subgraph hidden["…/domain/internal (hidden from the context too)"]
                CORE["Entities, domain services"]
            end
        end
    end
    subgraph billing["internal/modules/billing"]
        BGW["gateway/ordering"]
        BFACADE["billing.go"]
    end
    MAIN -->|"imports"| FACADE
    MAIN -->|"imports"| BFACADE
    MAIN -->|"imports"| BGW
    FACADE --> PORTS
    FACADE --> USERSIDE
    PORTS -->|"factories construct"| CORE
    BGW -->|"the one inter-context edge"| FACADE
    MAIN -.->|"blocked by compiler"| PORTS
    FACADE -.->|"blocked by compiler"| CORE
    BFACADE -.->|"blocked by compiler"| PORTS

The walls and what enforces them

Rule Enforced by
a context's internals are invisible to every other context and to cmd/ nested internal/: everything under internal/modules/<ctx>/internal/ is unimportable outside that subtree — the compiler
the domain core is invisible even to its own context's adapters a second nested internal/ under domain/ — the compiler
only the consumer's own directory may implement the consumer's ports the facade re-exporting nothing: the port's signature names types under <ctx>/internal/domain, unnameable elsewhere — the compiler
the assembly can wire driven adapters adapters live at modules/<ctx>/infra/…, outside the context's internal/ — the compiler admits it because the path is still rooted at the context directory
contexts meet only at the service seam only internal/modules/<ctx>/gateway/… may import a peer facade — depguard in CI, and it is one greppable directory
a peer does not import another context's adapters depguard — the one rule the infra/ placement genuinely costs
domain imports nothing from userside/infra; infra never imports userside; adapters never import each other depguard/go-arch-lint in CI, plus review
a deployment unit ships only what it uses the assembly's main import list (the ops runbook) + dead-code elimination
published contracts don't drag the service's dependencies api/ with its own tiny go.mod when outside consumers exist
the assembly point stays boring plain main.go wiring; extract internal/config only on real duplication

Composing two contexts — the facade, the service, the gateway

Go's answer to Java's user-side/service module is a method on the facade, and its answer to infra/<peer>-gateway is a package sitting just outside the consumer's internal/:

  • The provider exposes Service() returning a small exported interface declared in its facade package — the module's in-process API, with its own DTOs. It is a driving adapter like the HTTP handlers: it maps its DTOs onto the domain's commands and back.
  • The consumer declares the port it needs, in its own vocabulary, inside its internal/domain/, and the facade's Deps field simply has that internal type. No alias. main never names the type — it assigns a value into the composite literal and lets inference do the rest — and the absence of the alias is what keeps outsiders from implementing the port.
  • The gateway maps between the two. internal/modules/billing/gateway/ordering/ imports billing's internal domain (legal — it is inside billing's directory) and ordering's facade (legal — facades are exported), and returns billing's port type. It is the only package in the repository that names two contexts.
  • Whose struct crosses the seam? Neither's — and no shared type is forced. The gateway is the only place both vocabularies are nameable, so it converts: it takes billing's CustomerRef, passes a primitive to the peer, receives the peer facade's own DTOs, and returns billing's Activity. Ordering never learns billing's types exist and billing never learns ordering's. The one constraint this puts on the consumer's contract face is real though: it must export a constructor for every type a gateway has to produce (domain.ActivityOf(cents)), because Go's export rule is per-package, not per-directory — a struct with unexported fields is unbuildable even from a sibling package inside the same context.
  • Nothing else changes on extraction. The gateway package gains a sibling built on the published api/ordering client instead of ordering.Service, and main binds that one. Billing's domain, ordering's domain and every test above the gateway are untouched — see modulith & microservices.

The rule that makes this work is the same consumer-defined-interface idiom Go already uses everywhere: the port belongs to the caller, so the provider never learns it exists.

Dependency injection — pattern, not container (settled 2026-08-09)

Go has DI the pattern — constructor injection at a composition root — not a DI container, and the reference embraces that:

  • Default: hand-written constructor wiring in each cmd/ main. Small consumer-side interfaces and constructor-returns-interface make the whole assembly a handful of plain lines the compiler fully checks. The language has no annotations, so a runtime container must lean on reflection — hidden object graph, wiring errors moved from compile time to startup — which is exactly what the culture (and this reference) rejects.
  • Sanctioned escalation: google/wire when mains grow painful. Compile-time code generation from the same provider functions you already have: it changes who types the wiring, not the architecture — the graph stays visible and errors stay at build time.
  • Ruled out: fx/dig. A runtime reflection container reintroduces startup-time resolution errors and an obscured graph, and blurs "the assembly point is main.go".

Command dispatch — no mediator (settled 2026-08-09)

The registry-probing Mediator object is ruled out for Go; what stays is the principle behind it — commands as data, one explicit seam, adapters never importing concrete handlers — realized the way the language wants:

  • Default: Command/Query structs in internal/domain/, a per-use-case driving-port interface (PlaceOrders), the exported factory returning it — the shape this reference already uses. The seam is the port; main's wiring is the registry, checked by the compiler.
  • Cross-cutting (logging, metrics, tx): decorator functions (func(next PlaceOrders) PlaceOrders) applied at the assembly point — the same seam a mediator would give, without erasure.
  • Why the mediator is rejected: Go generics cannot type a heterogeneous handler registry with per-command result types, so dispatch degenerates to any plus type switches or reflection — Java's one unchecked cast becomes pervasive erasure, paying real type safety for cosmetic symmetry. And the indirection itself is anti-idiomatic: explicit call graphs and consumer-defined interfaces are the design center, and "the dependency list reads like the ops runbook" only works when calls are direct.

The layout dial — both layouts, basic default (settled 2026-08-14)

The JVM offers basic (flat trisection) and modulith because a JVM context costs five or six build files, so starting flat is a real saving. Go has no such saving to make: a context costs zero manifest files — the compiler reads directory names — so basic and modulith differ by two directory levels and one facade file. That measurement initially argued for shipping only the modulith here.

The ruling went the other way, and the reason is worth recording because it generalizes: manifest count is not the whole cost. The modulith also adds indirection a reader has to hold — a facade between the assembly and the domain, a modules/<ctx>/ level above everything, a peer seam that has no peer yet — and a single-context service can reasonably decline all of it. Cheap to build is not the same as free to read. Both layouts ship, basic is the default, and the zero-manifest finding becomes guidance on when to turn the dial rather than a reason to remove it: in Go, moving to the modulith later costs a facade file and a directory move, so there is no penalty for starting flat. See the four references compared for the same question answered per language.

Nothing left open

  • Shared DTO↔command mapping helpers between two transport adapters (2026-08-09): duplicate them. By design there should be scarcely any overlap between deployment units; the rare shared shape is cheaper as two copies than as a mapping package coupling units meant to evolve independently.
  • Package-name collisions are a naming rule, not a problem (2026-08-14): the facade internal/modules/ordering and the gateway internal/modules/billing/gateway/ordering are both package ordering, so any file importing both must alias one (orderinggw "…/billing/gateway/ordering"). Worth knowing because generated code that forgets the alias compiles until a second context arrives.

Examples

A module's facade — the whole exported surface of a bounded context:

// internal/modules/ordering/ordering.go
package ordering

import (
    "acme/internal/modules/ordering/internal/domain"
    "acme/internal/modules/ordering/internal/userside/http"
)

// Service is ordering's in-process API: the peer-facing driving adapter.
type Service interface {
    FindOrdersFor(ctx context.Context, customerID string) ([]OrderSummary, error)
}

type OrderSummary struct {
    OrderID    string
    TotalCents int64
    PlacedAt   time.Time
}

// Deps names the INTERNAL port types directly — no aliases. main can
// still assign into this literal; it just cannot write the type name,
// which is exactly the wall we want.
type Deps struct {
    Orders domain.OrderRepository // satisfied by …/ordering/infra/postgres
    Clock  clock.Clock
}

type Module struct{ placeOrder domain.PlaceOrder; find domain.FindOrders }

func New(d Deps) *Module { /* calls the exported domain factories */ }

func (m *Module) Service() Service          { return serviceAdapter{m.find} }
func (m *Module) MountHTTP(mux *http.ServeMux) { httpadapter.Mount(mux, m.placeOrder) }

go doc on that package prints Deps, Module, New, Service and OrderSummary — the peer-facing DTO and nothing from the domain. That listing is the context's contract.

The gateway — the only package naming two contexts:

// internal/modules/billing/gateway/ordering/gateway.go
package ordering

import (
    // legal: the gateway sits inside billing's directory, so billing's
    // internal domain is nameable here — and nowhere outside.
    bdomain "acme/internal/modules/billing/internal/domain"
    orderingmod "acme/internal/modules/ordering"
)

func New(peer orderingmod.Service) bdomain.OrderLookup { return gateway{peer} }

type gateway struct{ peer orderingmod.Service }

func (g gateway) ActivityFor(ctx context.Context, c bdomain.CustomerRef) (bdomain.Activity, error) {
    orders, err := g.peer.FindOrdersFor(ctx, c.Value())   // peer facade DTOs in…
    if err != nil {
        return bdomain.Activity{}, err
    }
    var total int64
    for _, o := range orders {
        total += o.Cents
    }
    return bdomain.ActivityOf(total), nil                 // …billing's vocabulary out
}

cmd/api/main.go as the configurator — construct, bridge, mount:

import (
    "acme/internal/modules/billing"
    orderinggw "acme/internal/modules/billing/gateway/ordering" // aliased: both are `package ordering`
    billingpg "acme/internal/modules/billing/infra/postgres"
    "acme/internal/modules/ordering"
    orderingpg "acme/internal/modules/ordering/infra/postgres"
)

func main() {
    db := postgres.MustOpen(os.Getenv("DATABASE_URL"))

    ord := ordering.New(ordering.Deps{
        Orders: orderingpg.NewOrderRepository(db),   // importable: infra sits outside internal/
        Clock:  clock.System{},
    })
    bil := billing.New(billing.Deps{
        Invoices: billingpg.NewInvoiceRepository(db),
        Orders:   orderinggw.New(ord.Service()),   // ← the one line extraction flips
    })

    mux := http.NewServeMux()
    ord.MountHTTP(mux)
    bil.MountHTTP(mux)
    log.Fatal(http.ListenAndServe(":8080", mux))
}

Related