Architecture¶
This chapter prescribes the top-level layout of a Go service, the
shape of its packages, the composition root pattern used to wire
dependencies, and the operator sub-architecture for projects that
ship a Kubernetes controller. The RFC 2119 keywords used here
(MUST, MUST NOT, SHOULD, SHOULD NOT, MAY) carry the senses defined
in chapter 00-introduction.md.
TL;DR¶
- Service source code MUST live under
<repo>/internal/so external consumers cannot import private packages. - Each business domain MUST follow a three-layer pattern
(
handler.go→service.go→store.go) with helpers co-located in the package that owns them. - A single composition root MUST construct all long-lived dependencies and inject them top-down. Package-level mutable globals MUST NOT carry runtime state.
- Projects that ship a Kubernetes operator SHOULD place the
operator under
<repo>/internal/operator/with its own composition root, reconciler, and stage registry. - New operator projects SHOULD adopt Crossplane v2 patterns (GA late 2025) rather than v1; Helm v4 (GA November 2025) MAY be used as the manifest packaging format.
Why this choice¶
The constraints driving the layout:
- Encapsulation. Go's
internal/directory enforces import visibility at the compiler level. Public packages under<repo>/pkg/invite premature exposure and create breakage when internals change. - Predictability for new contributors. A three-layer shape
(
handler.go,service.go,store.go) lets a new engineer locate the right file in seconds. Co-located helpers prevent premature shared-utility packages that grow into "utils" sinks. - Testability. A single composition root means each layer MAY be exercised with explicit fakes injected at the seam. Package globals defeat this and MUST NOT carry runtime state.
- Operator separation. A Kubernetes controller is a long-lived reconcile loop with a different lifecycle from an HTTP service. Sharing a binary is acceptable, but mixing the two composition graphs in the same package boundary obscures both.
Prescriptive guidance¶
Top-level layout¶
- Source code MUST live under
<repo>/internal/. - The HTTP/RPC entry point MUST live under
<repo>/cmd/<binary>/with amainpackage that calls into the composition root and does nothing else. - Public APIs that adopting consumers import (for example, generated
protobuf stubs) MAY live under
<repo>/pkg/only when the package surface is deliberately exposed and versioned. - The project MUST NOT rely on
golang-standards/project-layoutas authoritative. Authors MAY consult it as inspiration; the Go core team has publicly disavowed it as a standard.
Package boundaries¶
- Each business domain MUST own one package directory under
<repo>/internal/<domain>/. - Each domain package MUST contain at most three layers:
handler.go(or<domain>_handler.go) — request/response shapes, validation, RPC binding.service.go— domain logic, orchestration, policy.store.go— data accessors, typically backed by sqlc-generated code.
- Helpers used by only one domain MUST stay co-located in that
domain package. A
utilsorhelperspackage at the repository level SHOULD NOT appear. - Cross-domain helpers (for example, RPC error mapping or
pagination decoding) MAY live in narrow utility packages such
as
<repo>/internal/api/apiutil/.
Composition root¶
- Each binary MUST have a single composition root that constructs all long-lived dependencies in topological order: configuration → logger → database pool → repositories → services → handlers → router.
- Dependencies MUST be passed as constructor arguments. Package-level mutable state MUST NOT be used to share runtime dependencies between layers.
- The composition root MAY use a code generator (such as Wire) for boilerplate, but a hand-written constructor is equally acceptable when the dependency graph is small.
- The composition root MUST be the only place that reads configuration from the environment; downstream code MUST receive already-parsed configuration values.
Three-layer pattern with co-located helpers¶
The canonical layout for a domain package:
<repo>/internal/<domain>/
├── handler.go // RPC / HTTP entry points, validation
├── service.go // domain orchestration, policy
├── store.go // sqlc-generated accessors and wrappers
├── helpers.go // co-located helpers used only here
└── *_test.go // tests adjacent to the code under test
flowchart LR
Client[Client / RPC peer] -->|protobuf| Handler[handler.go]
Handler -->|domain types| Service[service.go]
Service -->|repository iface| Store[store.go]
Store -->|sqlc-generated SQL| DB[(PostgreSQL)]
Service -. uses .-> Helpers[helpers.go]
Handler -. uses .-> Helpers
Operator subsection¶
For projects that ship a Kubernetes controller:
- The operator MUST have its own composition root at
<repo>/internal/operator/wire/(or equivalent path) that constructs the controller-runtime manager, the typed client set, the stage registry, and the reconciler. - The reconciler MUST hold a per-resource lock so that two
reconcile passes against the same custom resource cannot
interleave. A
sync.Mapkeyed by namespaced name suffices for single-replica controllers; leader-elected multi-replica controllers MUST defer to Kubernetes leader election. - The reconciler SHOULD dispatch through a stage registry so that each reconcile step is an independently testable unit. A phase advancer SHOULD convert between proto enums (the wire format) and database string columns at the boundary.
- Proto-enum-to-database-string conversion MUST happen in a dedicated boundary package; downstream reconciler code MUST work in the typed enum form exclusively.
- New operator projects SHOULD adopt Crossplane v2 patterns rather than v1. Crossplane v2 went GA in late 2025 with five hard breaking API changes; v1 manifests do not parse cleanly against v2 CRDs and MUST be migrated before adoption.
- Operators that provision workload clusters SHOULD evaluate Cluster API (CAPI) as the canonical provisioning substrate.
- Helm v4 (GA November 2025) MAY be used as the manifest packaging format. New chart authors SHOULD start on v4; v3 charts continue to render but the v4 lookup-function semantics differ and MUST be tested before cutover.
Reference Implementation: Pioneer
The architecture pattern is exercised end to end in the canonical reference implementation:
pioneer/internal/api/credential/handler.gois a textbook three-layer split: ahandler.gothat handles RPC binding and validation, aservice.gonext to it for domain orchestration, and astore.gofor sqlc-backed data accessors, with co-located helpers used only inside the domain package.pioneer/internal/operator/wire/operator.gois the operator composition root. It assembles the manager, the typed client set, the stage registry, and the reconciler in topological order with no package-level globals.pioneer/internal/operator/lifecycle/reconciler.goshows the per-resource lock pattern (async.Mapkeyed by namespaced name) and the stage-registry dispatch.pioneer/internal/operator/phaseconv/convert.gois the proto-enum-to-database-string boundary converter; downstream reconciler code consumes the typed enum exclusively.
Pinned versions (snapshot 2026-05-08)¶
| Component | Version | Notes |
|---|---|---|
| Go toolchain | 1.26.x | The version MUST match the project go.mod |
| controller-runtime | 0.20.x | Reconciler / manager framework |
| Crossplane | v2 GA (late 2025) | Five breaking API changes from v1 |
| Cluster API | v1.10.x | Workload cluster provisioning |
| Helm | v4 GA (November 2025) | Lookup-function semantics differ from v3 |
| buf (proto tooling) | v1.50.x | Proto compilation and lint |
Adopting orgs MUST validate these versions against their own
toolchain constraints at adoption time. The snapshot date above
matches last-reviewed in this chapter's frontmatter.
Pitfalls¶
- Treating
golang-standards/project-layoutas inspiration only — the Go core team has publicly disavowed it as a standard. Authors who cite it MUST accompany the citation with that disavowal note so readers do not interpret it as authoritative. - Package-level globals smuggling state across handlers. A
shared
var db *sql.DBat package scope makes testing impossible and hides the dependency graph. Constructors MUST receive dependencies as arguments. - Cross-domain
internal/utils/packages. A "utils" package grows into an ungrep-able sink. Helpers used by one domain MUST stay co-located; helpers used by more than one domain MUST move to a narrow, well-named utility package. - Mixing operator and HTTP server composition. A single composition root for both binaries couples their lifecycles and obscures what the operator actually needs. Each binary MUST own its own composition root.
- Crossplane v1 manifests applied against v2 CRDs. The five breaking API changes in v2 MUST be migrated before manifests apply cleanly. Adopting orgs MUST budget migration time at the start of a v2 adoption.
- Reconciler without a per-resource lock. Concurrent reconciles against the same custom resource race on status updates. The reconciler MUST hold a lock keyed by namespaced name for the duration of a reconcile pass.
See also¶
00-introduction.md— RFC 2119 keyword definitions and doc conventions used throughout this chapter.02-data.md— the data layer that thestore.gofiles in this chapter call into.03-surface.md— the HTTP / RPC surface that thehandler.gofiles in this chapter expose.08-discipline.md— project-root engineering principles.- Crossplane v2 release notes: https://docs.crossplane.io/latest/whats-new/
- Cluster API documentation: https://cluster-api.sigs.k8s.io/
- Helm v4 documentation: https://helm.sh/docs/
- Go core team statement on project layout: https://github.com/golang-standards/project-layout/issues/117