0018 — Shared platform code in common/
Service-invariant platform code lives in common/, domain models stay unshared, and spec-shaped code stays generated.
Decided. Service-invariant platform code lives in common/<package> and is imported
by every service. services/<name>/internal/ keeps only what is specific to that
service. common/bus already works this way; this decision states the rule and the
boundary rather than inventing a location.
Extraction is mechanical: code moves, importers are updated, and the observable behavior of each service is unchanged. Giving a service a capability it does not have today is a separate change that does not travel with a move.
What belongs in common/
All three must hold:
- It references no generated per-service type —
db.Queries, migrations, Cedar policies. - Its behavior does not vary by service. Configuration may vary; behavior may not.
- A second caller exists today. Naming a location does not relax that rule.
Moves to common/ | Stays in the service |
|---|---|
logging, reqmeta, response, health, testdb | db/ (sqlc), migrations/, policies/ |
envelope — bus wire format, Why, Headers | domains/ and internal/domains/ |
| HTTP middleware: request id, request log, panic recovery, error mapping | The router assembly that composes them |
The publisher is the obvious next candidate and is deliberately not in that list.
Its transport is built on the per-domain fanout topology from
0016, which accounting has
not adopted — it still publishes to the shared core-events / pii-events exchanges
with routing keys. Criterion 3 therefore fails: there is one caller, not two. Moving it
now would mean either bending the shared transport around a topology one service is
leaving, or migrating accounting's outbox as part of an extraction that is supposed to
be mechanical. It moves once accounting adopts 0015 and 0016.
This does not authorize sharing domain models. Architecture
already rules that out: one service owns a model, others use it, and what is shared is
the identifier and its meaning, not the type. common/ is for platform mechanics only.
Routers are composed, not shared. Accounting runs Cedar authorization and dev-auth that
template does not, so a shared NewRouter would grow a flag per service. The middleware
is shared; each service assembles its own chain.
Alternatives
- Keep copying into each service (status quo). Rejected on evidence below. The cost is not the typing, it is that copies stop receiving fixes.
- Separate Go modules with semantic versions. Buys staged rollout, but reintroduces drift as version skew and adds release ceremony for one repository and one team.
pkg/instead ofcommon/. Naming only —common/busalready exists and is imported by twocmd/tools and the template service.- Generate all of it (0011). Rejected for invariant code: a generated copy is still a copy, and fixing a logging bug would mean regenerating and redeploying every service. Generation stays for code whose shape follows the spec.
- One
common/platformpackage. Rejected: an unclear boundary that invites import cycles.
Why
The two services are not tidy copies. accounting is a stale fork that never received
template's later work:
reqmetais 16 lines against template's 74 —actorFIDonly.- No request id at all, so nothing there can be correlated.
- No response envelope package, so it does not implement the platform response contract.
- Its publisher dials AMQP directly, with no transport seam and no per-domain outbox, predating 0015 and 0016.
The files that were copied faithfully differ by about eight lines each — the service name. So the copies that matter have drifted, and the ones that have not drifted did not need to be copies.
The mechanical cause is services/<name>/internal/: the Go compiler forbids importing it
from another service, so copying was the only option available.
What it rests on
- One Go module, one team, so an update lands atomically and there is no version to skew. If services ever need independent deploy schedules with pinned platform versions, revisit the separate-modules alternative — that is the condition that changes this.
- Two services today, both in this repository.
- sqlc generates
db.Queriesper service, which is whycommand.Executecannot move as written.
Consequences
- Changes to
common/are atomic across every service: no drift, but no staged rollout either. A breaking change is fixed everywhere in the same commit. command.Executeneeds one type parameter to escape*db.Queries. Template already takesnewStore func(*db.Queries) Store, so the seam is half-built. It is the last piece to move, not the first.- Middleware in
common/takes the service name as an argument rather than reading a package constant. - Adopting a shared package does not by itself change behavior. Where accounting has a diverged copy it takes the superset and gains unused API; where it has nothing, wiring it up is a separate change, tracked in FRICTION.md.
common/is not a dumping ground. Code failing the three-part test stays in the service, and the generator remains the answer for spec-shaped code.- Shared types lose their per-service OpenAPI schema prefix, because the namer keys off
the owning package.
TemplateHeaderandTemplateWhybecomeHeaderandWhy, and both services' specs now declare them identically. That is the correct outcome — one contract, one schema — but cmd/mergeopenapi/main.go had to stop treating any repeated schema name as a conflict and start comparing the definitions.
What landed
logging, reqmeta, response, envelope, health, testdb, and httpapi are in
common/, with both services importing them. Accounting gained what it was missing in
the same pass: structured logging, correlation ids, request logging, panic recovery, the
{header, payload} response envelope, and the request id on its outbox rows and broker
messages. command.Execute and the publisher stayed per-service for the reasons above.
Two bugs the compiler could not catch showed up during the move, both of the same shape
— shared code silently keeping a default from the service it came from. common/testdb
ran template's migrations for accounting's tests, and envelope.From hardcoded
source: "template", which would have mislabelled every accounting event on the bus.
Anything moved into common/ needs its per-service constants turned into parameters,
and nothing checks that they all were.
0017 — Structured logging and request correlation
One slog logger per process, JSON on stderr when deployed, and a request id that follows an event to its consumers.
0019 — The handler layer: shared, generated, or hand-written
How a piece of handler code varies between services decides whether it is shared in common/, generated from the entity spec, or written by hand.