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.

ruling

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:

  1. the actor, visible state, and action they can take.
  2. the exact information required to render every state: loading, empty, populated, denied, recoverable failure, completed action.
  3. the command's intended outcome and the semantic errors the interface must distinguish.
  4. one named operation and response model for that interaction.
  5. 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 needdesigndo 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-keys file, 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 structure
GET /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 calls
function 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 state
const isRunActive = run && !["completed", "failed"].includes(run.status);
const currentPhase = isRunActive ? run.phase : "complete";
const displayStatus = run?.error ? "error" : run?.status; // wrong: error is a string
const 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 this
return (
<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 projection
GET /workflow-workspace/:runId
-> { stages, displayStatus, permittedActions: ["retry"], nextStep: "review-frames" }
// interface reads prepared state
function 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 once
async 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 action
const 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.

search pages

go to any page