interface-shaped api
rule
design every API for the interface that consumes it. start from the screen, interaction, workflow, or integration outcome; define the exact request and response that lets the consumer render and act without reconstructing meaning from generic records. the API is a consumer contract, not a database mirror or a reusable bag of fields.
why
a generic API forces every consumer to duplicate business logic (status classification, null semantics, composition), creates N silent copies of the same decision, and makes field renames cascade across every caller that reimplemented its meaning. each interface-specific operation keeps the decision in one place (the service) and makes the route contract semantically complete.
consumer-first, service-owned
the interface determines what the transport contract must express. the service owns authorization, invariants, persistence, and business decisions. UI code consumes a purpose-built contract; it does not assemble a product model from unrelated generic endpoints.
design from the consuming interface outward
for each interface action, write down:
- the actor, visible state, and action they can take.
- the exact information required to render every state: loading, empty, populated, denied, recoverable failure, completed action.
- the command's intended outcome and the semantic errors the interface must distinguish.
- one named operation and response model for that interaction.
- the OpenAPI schema, Fern client operation, query hook, and query key derived from that operation.
the consuming interface stays semantic: it renders canRetry, nextStep,
displayStatus, or a typed diagnostic code when those are real product facts,
not inferred from timestamps, nullable fields, database statuses, or
error-message strings.
prefer interaction contracts over generic records
| consumer need | design | do not design |
|---|---|---|
| a workflow workspace shows progress and its next safe action. | readWorkflowWorkspace returns stages, semantic status, permitted actions, and operation identity. | listWorkflowRuns plus raw rows that the client interprets into progress. |
| a channel page activates an integration. | activateChannelIntegration accepts the interaction inputs and returns the new displayable state. | a generic updateChannel patch endpoint that exposes persistence fields. |
| a list view needs cursor pagination. | listChannelInvites accepts its filter and cursor and returns items plus next cursor. | a frontend-created query over a broad listInvites response. |
separate interfaces may have separate operations for the same underlying entity — not duplication when each has a different consumer outcome. share service logic and domain entities below the route; do not force interfaces to share an accidental transport shape.
contract ownership and generation
- route schemas declare the interface-shaped request, success response, and typed diagnostic responses.
- the live route graph exports OpenAPI from those schemas.
- Fern generates the only client used by applications.
- TanStack Query hooks call generated client operations and put each operation's
canonical cache identity in its owning
query-keysfile, per query-key ownership. - UI components call hooks and render semantic fields; they do not import route schemas, database models, or raw HTTP clients.
Interface state and interaction-> interface-shaped route contract-> service invariant and domain operation-> OpenAPI export-> Fern-generated client-> query key and hook-> interface rendering
review questions
- which exact interface consumes this operation, and what user action or state does it own?
- can that interface render every required state without parsing strings or reimplementing service logic?
- does the response expose only consumer-relevant semantics, not a raw persistence record?
- does a second interface have a genuinely different outcome warranting its own named operation?
- were OpenAPI, Fern output, query keys, hooks, and callers regenerated and updated in the same change?
good and bad
bad
// three generic endpoints mirroring table structureGET /channels/:id -> { id, name, createdAt, config: {...} }GET /workflows/:id -> { id, status, startedAt, finishedAt, error }GET /runs/:id -> { id, workflowId, status, output, phase }// interface assembles one screen from three callsfunction WorkflowWorkspace({ runId }: Props) {const { data: run } = useWorkflow(runId);const { data: workflow } = useWorkflowDefinition(run?.workflowId);const { data: channel } = useChannel(workflow?.channelId);// interface must now decode persistence into UI stateconst isRunActive = run && !["completed", "failed"].includes(run.status);const currentPhase = isRunActive ? run.phase : "complete";const displayStatus = run?.error ? "error" : run?.status; // wrong: error is a stringconst nextAction =isRunActive && run?.phase === "learning" ? "continue" : "reset";const stages = workflow?.stages || []; // different API call still needed// when a second screen needs workflow progress, it reimplements all thisreturn (<div>Status: {displayStatus} Phase: {currentPhase}{/* if you add a new phase, every consumer must handle it */}Action: {nextAction}</div>);}
the interface reconstructs meaning because the API mirrors persistence. add a new enum value ("paused"): this code renders it as the status (correct by accident) but fails to compute nextAction (missing branch). a second screen reimplements the same classification logic differently.
good
// one semantic operation owns the workspace projectionGET /workflow-workspace/:runId-> { stages, displayStatus, permittedActions: ["retry"], nextStep: "review-frames" }// interface reads prepared statefunction WorkflowWorkspace({ runId }: Props) {const { data: workspace } = useReadWorkflowWorkspace(runId);return (<div>Status: {workspace.displayStatus}Stages: {workspace.stages.map(s => <Stage key={s.id} {...s} />)}Next: {workspace.nextStep}{workspace.permittedActions.includes("retry") && (<button>Retry</button>)}</div>);}// service resolves all decisions onceasync function readWorkflowWorkspace(runId: string) {const run = await runRepo.readById(runId);const workflow = await workflowRepo.readById(run.workflowId);// service owns: is it active, what phase, what's the next safe actionconst isActive = !isTerminalRunStatus(run.status);const displayStatus = isActive ? "in progress" : "complete";const nextStep = computeNextStep(run.phase, workflow.stages);const permittedActions = isActive ? ["retry"] : ["reset"];return { displayStatus, nextStep, permittedActions, stages: workflow.stages };}
one operation names the workspace outcome and owns all business logic. add a new status: only the service changes; every consuming screen sees the prepared state without reimplementing decisions.
do not
- design an endpoint from a table shape, ORM entity, or presumed generic reuse.
- make the interface fetch several unrelated endpoints to recreate one screen state when the service can own that composition.
- return internal status codes, provider labels, raw error strings, or nullable field puzzles when a semantic consumer field is required.
- hand-write a parallel API client or duplicate generated operation types.
- make a broad endpoint and let callers invent their own incompatible meanings.