Skip to content

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.goservice.gostore.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 a main package 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-layout as 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 utils or helpers package 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.Map keyed 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.go is a textbook three-layer split: a handler.go that handles RPC binding and validation, a service.go next to it for domain orchestration, and a store.go for sqlc-backed data accessors, with co-located helpers used only inside the domain package.
  • pioneer/internal/operator/wire/operator.go is 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.go shows the per-resource lock pattern (a sync.Map keyed by namespaced name) and the stage-registry dispatch.
  • pioneer/internal/operator/phaseconv/convert.go is 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-layout as 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.DB at 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