api route ownership
rule
an API route owns a consumer-facing transport contract — not business logic, not a persistence-shaped record. it:
- names one interface outcome
- validates the HTTP request
- establishes authorization
- delegates to the domain service
- returns the semantic success or diagnostic envelope the consumer needs
the owning service holds domain invariants, composition, persistence. a route is thin only after it owns the boundary completely: input shape, status codes, OpenAPI operation identity, safe error translation.
why
- a route with domain logic leaks persistence shape into the API contract and scatters the same decision across routes.
- when a service rule changes (e.g. what "active" means), every route that reimplemented it must be hunted down and updated.
- a thin delegating route keeps business logic in one searchable place and makes the transport contract stable: the API names what the consumer needs, the service owns how to produce it; route changes stay transparent to callers.
a route starts with an interface state
before adding a path, identify the consuming surface and write its states.
| interface question | contract consequence |
|---|---|
| what is this screen, command, or webhook trying to accomplish? | one operation name that carries that outcome: readActiveProcessStatus, not getResource. |
| what must it render or decide? | response fields are semantic UI facts, not raw database columns. |
| which failures change the next action? | typed status and diagnostic response schemas, not a generic exception string. |
| which identity and scope guard the operation? | explicit path/query/body schemas and authorization at the transport boundary. |
- the API may return a projection composed from several tables or systems when that is the consumer's actual state.
- it must not push that reconstruction into the UI just to make the endpoint look generically reusable.
route shape
consumer outcome-> OpenAPI operation ID + request/response schemas-> route: authenticate, authorize, parse, map known errors-> service: resolve identity, enforce invariants, compose projection-> repository/provider: read and write authoritative state
- the route does not import UI components.
- the service does not import Hono, OpenAPI, or request objects.
- a repository does not decide HTTP status or consumer copy.
case study: consumer status projection
a process panel needs the active run's phase and accumulated output — not a raw run row or several unrelated endpoint responses.
- the named status operation returns a nullable active-run projection.
- its service resolves identity, reads authoritative lifecycle state, maps durable rows into display facts.
- the query hook validates that shared contract before returning data to UI.
this centralizes the meaning of "active process status" at the service boundary while keeping the interface free of database and lifecycle reconstruction.
what would have been wrong
a genericGET /resources/{id} plus raw process-run fields would force every consumer to decide whether a run is active, which phase applies, how to order layers, and what null means. those are product facts, so they belong in the named operation.why this is still thin
the route validates and envelopes. the service performs identity resolution, concurrent reads, null semantics, projection mapping. the route has no knowledge of database rows or panel rendering.review and verification
- name the consumer outcome and locate its interface owner.
- define request, success, and diagnostic response schemas in the route's OpenAPI contract.
- confirm the service can produce the response without UI code importing persistence or provider models.
- regenerate OpenAPI and Fern, then typecheck the consuming hook.
- verify the interface states that motivated the endpoint: empty, populated, denied, recoverable failure, completed command as applicable.
do not
- expose ORM or table records as the API by default.
- let a route contain domain branching the service should own.
- invent an all-purpose
get,list, orupdateoperation when the consumer outcome is more specific. - return raw provider failures or have the UI infer its next action from text.
- share one transport shape across interfaces whose outcomes or authorization semantics differ.
good and bad
bad
// route leaks raw persistence rowapp.get("/workflow-runs/:id", async (context) => {const run = await database.query.workflowRun.findFirst();return context.json({id: run.id,status: run.status, // "queued" | "running" | "completed"startedAt: run.createdAt, // null for queued runscompletedAt: run.finishedAt, // null for running runs});});// interface must now decode persistence into UI statefunction ProcessPanel({ runId }: Props) {const { data: run } = useWorkflowRun(runId);// interface is now responsible for understanding what "active" meansconst isActive = run && (run.status === "queued" || run.status === "running");const phase = isActive ? (run.startedAt ? "running" : "queued") : "complete";const output = run?.output ?? "waiting to start";// if a second panel needs this, it reimplements the same classification logic}
this scatters "what does active mean" across every consumer. when the backend adds a new status like "paused", every interface must discover and handle it, or it renders garbage (phase undefined, wrong output displayed).
good
// route owns the consumer outcomeapp.openapi(ReadActiveWorkflowStatusRoute, async (context) => {const input = ReadActiveWorkflowStatusRequestSchema.parse(context.req.param(),);return context.json(await readActiveWorkflowStatus(input));});// service resolves all business logicasync function readActiveWorkflowStatus(input: ReadActiveWorkflowStatusRequest,): Promise<ActiveWorkflowStatusResponse | null> {const run = await workflowRunRepo.readById(input.id);if (!run || !isActiveRunStatus(run.status)) return null;return {phase: run.startedAt ? "running" : "queued",output: run.output || "waiting to start",};}// interface reads the prepared contractfunction ProcessPanel({ runId }: Props) {const { data: status } = useReadActiveWorkflowStatus(runId);if (!status) return <div>Workflow complete</div>;return <div>{status.phase}: {status.output}</div>;}
the route names one outcome and owns its shape. the service resolves identity, invariants, business classifications. when a new status is added, only the service changes; the interface reads the same contract.