contract generation graph

situation

several apps consumed the same API contract, updated three different ways:

  • app A: local code generator run after every route change.
  • app B: hand-written CreateWorkflowInput type updated by hand.
  • app C: global root script that only worked after npm generate then npm build, in that order.

when the API route changed:

  • app B's type staled immediately; one developer knew to regenerate.
  • app A's client sometimes regenerated at the wrong time, skewing against app C.
  • the root sequence broke silently when generate was skipped or CI ran build first — no declared dependency, so either order was "valid" to the task system. the error surfaced in staging after the mistake was committed.

why

undeclared build order is invisible authority: whoever ran the commands last decides what fresh means. generated contracts are derived output whose freshness the build itself must establish, not a discipline each developer remembers. declared producer/consumer edges move that discipline into the graph — a wrong order fails the build instead of surfacing later as a staging type mismatch.

invariants

  • an authored route/schema is the only hand-edited contract authority.
  • generated API descriptions and clients are derived output, never repair targets.
  • every consumer reads the generated package's public surface, not its source internals.
  • a clean checkout produces the same build without relying on command history.

decision process

candidatereasoningdecision
generator command in every consumer buildduplicates ownership; consumers can generate incompatible outputrejected
manually repaired client beside generated outputunblocks one error; next generation reintroduces itrejected
opaque root script with fixed sequencehides real edges; breaks incremental/cached executionrejected
declared producer/consumer tasks + public package boundariesmakes authority, freshness, and cache visible to people and toolsselected

resulting graph

  • the contract package owns generate and its declared output; its build depends on that local generation.
  • consumers depend on the contract package's public build output, then import its generated client through the package name.
  • the root invokes the graph; it owns neither a second client nor a special generation path.
  • repair path: stale client → inspect authored route/schema and generator config → regenerate at the producer → rebuild consumers. never edit a type where a consumer observes the mismatch.

verification

  1. remove derived client output in a disposable clean state.
  2. run the repository build graph without a prior manual generation command.
  3. confirm the producer generates before consumers typecheck or build.
  4. inspect a second unchanged run for the intended cache behavior.
  5. search consumers for deep imports and handwritten copies of the generated contract; neither should remain.

good and bad

bad

// apps/app-b/src/api.ts — written by hand, must be kept in sync
export type CreateWorkflowInput = {
channelHandle: string;
title: string;
description?: string;
};
// apps/backend/src/routes/workflows.ts — source of truth
export const createWorkflow = route
.post("/workflows")
.input(
z.object({
channelHandle: z.string(),
title: z.string(),
description: z.string().optional(),
scheduledAt: z.date().optional(), // ADDED — app B's type now stales
}),
)
.handler((req) => {
// ...
});

when the route adds scheduledAt, app B's type is obsolete. no build-time verification exists — the compile succeeds and only a live test reveals the mismatch. version skew causes silent corruption: the field is sent but the type claims it is absent.

good

{
"tasks": {
"backend#generate": {
"outputs": ["generated/openapi.json"],
"cache": false
},
"client#generate": {
"dependsOn": ["backend#generate"],
"inputs": ["generated/openapi.json"],
"outputs": ["src/api.generated.ts"]
},
"app-a#build": {
"dependsOn": ["client#generate"],
"inputs": ["src/**/*"]
},
"app-b#build": {
"dependsOn": ["client#generate"],
"inputs": ["src/**/*"]
}
}
}

the task system enforces the sequence: build cannot run before generate. a route change reruns the generate task automatically; every consumer's build depends on that fresh output. version skew is impossible — all apps consume the same generated client from the same client#generate output.

search pages

go to any page