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 acmd/binary —cmd/api/,cmd/consumer/,cmd/cron/— with its Dockerfile beside itsmain.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 underinternal/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 aninternal/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, soDepscan name them. Don't: a probe showed that onceCustomerRefandActivityare aliased, any package in the repository compiles an implementation of billing's port. Alias nothing — giveDepsfields the internal type directly — and the same probe fails withwant 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, somainstill constructs, holds and passes the value; it simply cannot write the type's name.go docon a pure-consumer facade then prints exactly three symbols —Deps,Module,New— and on a provider, those plusServiceand 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 underdomain/internal/…, plususerside/…(handlers, mappers). Driven adapters sit beside thatinternal/, not inside it — see the next bullet. The two nestedinternal/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 thedomainsubtree. 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 callsdomain.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 inapi/<context>/at the root when they're published to other teams, necessarily outsideinternal/since publication is the point. The contract artifact exists only where the contract is real (nothing for cron). Giveapi/its own tinygo.modwhen 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'sinternal/, inside its directory (corrected 2026-08-14). The obvious placement, under the context'sinternal/, does not compile:cmd/api/main.gocannot 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'sinternal/rule admits anything rooted at the parent ofinternal/— so…/ordering/infra/postgresstill imports…/ordering/internal/domainlegally, whilecmd/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 compiledmemory.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. Sodepguardmust forbidmodules/<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'sinit()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.
- The residue is the layout's one genuine linter rule. Adapters outside the
context's
- 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'sinfra/<peer>-gateway. It sits outside the consumer'sinternal/so the assembly can construct it, and inside the consumer's directory so it can still see the consumer's own ports. Onegrepovergateway/lists every inter-context edge. - The assembly point is each
main.go. Explicit constructor wiring, no DI framework: construct each module, hand a peer'sService()to the gateway that wants it, mount the user-side adapters on the server. Shared assembly helpers can live ininternal/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,
wireas 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-lintin CI:domainimports nothing fromuserside/infra,infranever importsuserside, adapters never import each other, and onlygateway/…packages import a peer module. Ago.workmulti-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'sDepsfield simply has that internal type. No alias.mainnever 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'sActivity. 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/orderingclient instead ofordering.Service, andmainbinds 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/Querystructs ininternal/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
anyplus 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/orderingand the gatewayinternal/modules/billing/gateway/orderingare bothpackage 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
- Hexagonal architecture — the shared house principles this note realizes; the hub for all four reference implementations.
- Modulith & microservices — what the facade/gateway seam is for, and the extraction procedure it enables.
- The four hexagonal references compared — where Go's walls sit against the other three, and why Go pays least for them.
- Go modules — why one module per service is the grain Go's tooling wants.
- Deep dive Go — interfaces-at-the-consumer, the idiom doing the heavy lifting here.
- API documentation in Go — the contract formats
the root
api/directory carries and how each maps onto the Go toolchain.