API contract
One source for the HTTP wire, from route tables to response schemas, and what the api, the clients and the OpenAPI document derive from it.
The HTTP wire of the product has one owner, @workspace/api-contract. It holds, per module, the XxxApi type the apps program against, the DTO types, the zod schemas of bodies, queries and responses, the mappers from the domain to the DTOs, the route tables and the error keys. Everything else is derived from it: apps/api serves it, @workspace/api-client calls it, and the OpenAPI document describes it.
What lives where
| Concern | Owner | Derived by |
|---|---|---|
| Methods and paths | the route tables in api-contract/src/routes | contractRoute(table, key) in the api, client.send(route, { params, query, body }) in the clients |
| Request bodies and queries | api-contract/src/schemas/<module>.ts | the api validates with them; the clients send what they infer |
| Response shapes | api-contract/src/schemas/<module>.ts, checked against the DTO types, named by each route | the api documents them; client.send parses with the route's |
| DTO types | api-contract/src/dtos.ts and the module files | the XxxApi return types |
| Errors | api-contract/src/errors.ts and the per-module xxxApiErrors | the api maps domain errors; the clients rehydrate them |
Authentication is not part of the contract. The guards, the session and the API key live next to the api.
Routes
A route table names each operation once, with its method, its full pattern, {param} placeholders included, and the schema of its answer:
export const groupsRoutes = {
get: { method: "GET", pattern: "/groups/{id}", response: GroupDtoSchema },
delete: { method: "DELETE", pattern: "/groups/{id}", response: NoContentSchema },
} as const satisfies RouteTable<GroupsApi>;The api mounts every module at / and takes the pattern as it is through contractRoute; the clients call it through client.send(route, …), which takes the method and the path from the entry, so a client cannot speak another verb than the table, and resolves to what the route's response parses, so a client cannot type an answer the table does not declare. response is required: a route without one does not type-check. params is required exactly when the pattern has {param} segments, and body exactly when the route declares one. renderPattern is the only parser of {param}: the contract never carries framework syntax. A test in the api walks every table and checks that each entry is served by the mounted router, and that every served route comes from a table, so a route cannot exist on one side only.
Request schemas
Bodies and queries are zod schemas in the contract, strict on input: unknown keys are stripped, bounds are the wire's own (input-limits.ts). Business rules stay in the services of @workspace/training, which throw typed errors; the wire schema never duplicates them.
Response schemas
Every response has a zod schema in the contract, and that schema is checked against the type the XxxApi method returns:
export const GroupDtoSchema = z.object({
id: z.string().uuid(),
name: z.string(),
// …
}) satisfies z.ZodType<GroupDto>;satisfies z.ZodType<Dto> is the lockstep. A field added to the DTO type without its schema, or a schema narrower than its type, fails the type-check of the package. Where the api returns a domain type as is (BoardResult, BoardTheme, PublicLink…), the schema is checked against that type. Schemas that stand for an opaque or union payload (DealViewSchema) keep their inferred type as the wire type.
The views the apps consume may differ from the wire: a null limit becomes Number.POSITIVE_INFINITY in EntitlementsView or PlanView. Then the route answers the wire type (EntitlementsDto, PlanDto, SubscriptionDto) and the contract holds a toXxxView function the client applies to the parsed DTO.
Reading a response
client.send parses every successful answer with the route's response: unknown keys are stripped, nothing else is coerced. A 204 route declares NoContentSchema and resolves to undefined. An answer the schema refuses, or a body that is not JSON, throws an ApiResponseInvalidError: it names the route, the status and the issue paths, never the payload, and it is not an ApiError, so the apps report it as unexpected rather than explain it as a refusal.
A response schema must accept everything the api serializes, so it is never stricter than its DTO type. The boards are parsed in full like any other payload: the api degrades a corrupt game payload of the deal store into the board's diagnostics, which the schema accepts, rather than serve it as is.
The api is a naming layer
apps/api/src/modules/<module>/<module>.schema.ts declares no shape. It imports the contract schema and attaches the OpenAPI component name:
export const GroupDtoSchema = BaseGroupDtoSchema.openapi("GroupDto");Two exceptions keep the document readable, and both stay typed:
- a nested component that must keep its own name in the document is re-attached by recomposing the parent from the contract's
.shape, and the result is checked with the samesatisfies z.ZodType<Dto>; - a server-side default that the contract does not know (the grid of plans a pre-grid app bundle reads) extends the contract body with
.extend().
Query schemas of listings (page, includeArchived) and path parameters are the api's own, as they describe the server's reading of the URL.
The OpenAPI document is therefore a projection of the contract. Comparing the document before and after a change of the contract is the review tool: a difference is either an intended change of the wire or a bug.
Checklist for a new operation
- Add the method to the
XxxApitype and the DTO type it returns. - Add the response schema,
satisfies z.ZodType<Dto>, orNoContentSchemafor a 204. - Add the route to the module's table with its
response, and the body or query schema if any. - In the api:
contractRoute(table, key), the contract schemas with their.openapi()names, the handler. - In the client:
client.send(route, …); the answer comes back parsed and typed by the route. - Run the api tests: the route coverage test and the integration suite are the oracle.
Adapter packages
How a third-party service becomes a package named by its capability, with the vendor inside, the contract in training and tests at three levels.
i18n strategy
Where translations live, namespaces vs key prefixes, static keys, translation hooks, notifications and the auto-translate opt-out.