Flow
Decisions

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:

  1. It references no generated per-service type — db.Queries, migrations, Cedar policies.
  2. Its behavior does not vary by service. Configuration may vary; behavior may not.
  3. A second caller exists today. Naming a location does not relax that rule.
Moves to common/Stays in the service
logging, reqmeta, response, health, testdbdb/ (sqlc), migrations/, policies/
envelope — bus wire format, Why, Headersdomains/ and internal/domains/
HTTP middleware: request id, request log, panic recovery, error mappingThe 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 of common/. Naming only — common/bus already exists and is imported by two cmd/ 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/platform package. 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:

  • reqmeta is 16 lines against template's 74 — actorFID only.
  • 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.Queries per service, which is why command.Execute cannot 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.Execute needs one type parameter to escape *db.Queries. Template already takes newStore 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. TemplateHeader and TemplateWhy become Header and Why, 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.

On this page