Few big services with capabilities added inside, or many small modules? And
composability inside one system, or across a contract? The estate has taken
this decision three ways and lived with each, so this guide can say what
each way bought and cost rather than argue from principle. It ends with the
tells that say which way fits a given case, not with a winner; adr/001
records which way the module tree takes and why, and this guide is the
reasoning behind that being a choice rather than a law.
Audience: anyone about to add a capability and deciding where it goes.
The three ways, as lived#
App V5: as few services as possible, add capabilities inside#
The owner's description, 2026-08-18: "we deploy as few services as possible, Render + Vercel, and add capabilities to existing services. Pipeline composability existed but it was all in-system, this caused issues when integrating the external extractor. It did have a lot of benefits like not having to reimplement a lot of the same functionality, but then what we had was what all we had, and upgrades to the system needed architectural headroom."
What it bought: one runner, one auth, one credits system, one observability
stack, one deploy; a new capability was a method in the registry and a
pipeline step, and every capability got retries, breakers, heartbeats,
credits, and Sentry for free (../instances/enhancement-product.md). For
the first years and the first customers this was the fastest possible way
to add capability.
What it cost: the composability was the pipeline's, so anything not shaped
like a pipeline step (the external extractor with its own job lifecycle;
M rows from N items) fought the system instead of plugging in. Upgrades
needed headroom the monolith did not have: a fitment agent's memory profile
became every worker's ceiling (worker OOM 2026-05-29), a cron's
materialization became the whole notify path's OOM (2026-05-28), and one
shared X-Api-Token was every machine caller's credential. And when
customers needed a different data model, the answer was a new app with the
capabilities copied (00-charter.md, "The problem").
The MVPs: an app per customer shape, capabilities copied in#
speedway and walmart-mvp each carry their own runner and their own copies of part-type matching, scraping, normalization, and content generation, tuned to their data model. What it bought: shipping speed for a customer whose workflow did not fit App V5, and freedom to choose Firestore or Postgres per app. What it cost: the ninety-percent duplication the owner named, QA and edge cases and credits and logs done again per app, and two runners with gaps the original did not have (walmart lost heartbeat and cancel in its rewrite; speedway copy-pasted its checkpoint loop three times).
The runner forks: one harness, cloned per capability set#
runner-service and services-api share a runner byte-for-byte in most of
app/ and diverge only by features one picked up. What it bought: a proven
runner reused with almost no cost, and a capability set that could be scoped
per deployment. What it cost: the clones drift silently (the four-file diff),
and nothing enforces that a fix lands in both. It is the accidental version
of the module tree.
What the module tree chooses, and why it is a choice#
adr/001: one runner implementation, many module deployments, each with a
manifest, its own version and identity audience, callable standalone and
composable through requires/provides. It takes App V5's "do not
reimplement" benefit by making the runner a template and a library instead
of a deployment, and it takes the MVPs' "different shape per app" benefit by
putting the shape in the app and the capability in a module. It pays for
that with N deployments to provision and a shared identity service.
That trade is right when: capabilities are called from more than one app; capabilities need different scaling or memory profiles; two versions of one capability must run at once; a capability has an outside lifecycle (a vendor job) that a pipeline step cannot model. It is wrong, or at least premature, when: one app calls one capability and no second caller is coming; the team is one person shipping this week; the "capability" is a data transform with no vendor, no model, and no state, which is a function, not a module.
The tells#
Signs the current granularity is too coarse (the App V5 failure):
- a new capability needs a change to shared runner code to fit
- one capability's resource profile sets everyone's ceiling
- an external system with its own job lifecycle has to be wrapped as if it were a step
- "what we had was what all we had": a new shape (M≠N, a review step, a second environment) means an architectural change, not a config change
- one credential, one deploy, one release for everything
Signs it is too fine (the MVP failure in a different costume):
- the same capability exists in three places with three sets of tests
- a fix has to be applied N times
- each service has its own answer to auth, logging, credits, retries
- provisioning a new module takes longer than writing its payload
Signs it is right:
- adding a capability is a payload plus a manifest entry, and it gets retries, cancel, metering, logs, and identity without writing them
- a module can be deployed, versioned, and scaled without touching another
- an app can swap one module for another version by changing what it calls
- an outside system plugs in as a module with the same surface as an inside
one (
../instances/README.md, the extractor as an instance)
The headroom rule#
App V5's lesson stated as one sentence: build the seams before you need them, because adding a seam to a running system costs more than the seam ever would have. The contract's seams (runner/payload, module/app, outcomes/outputs, worker run/transform, verifier port, queue port, storage port) are each one of those, and each one is there because an instance needed it after the fact.
Related#
../adr/001-standalone-modules.md, the ruling../canon/01-runner-and-payload.md, the seam that makes many modules affordable../canon/14-graceful-degradation.md, several versions at once01-outputs-as-a-transform.md, the M≠N case in full../instances/enhancement-product.md, "Retrospective"