Repository navigation
Adding an Endpoint
The end-to-end runbook for shipping a route. Copy the pattern from
courses.routes.ts+courses.test.ts— they are the reference pair. Status: current.
This assumes the route belongs in the current API. Check the
API Catalog first: if the endpoint is still a 🚧 design in
endpoints.md, the
contract is not settled and the design doc must change in the same PR.
Before writing code, write down:
-
Method and path — under
/v1, plural resource nouns, no/apiprefix (the deployment adds it). - Auth — public, session-required, or session-optional.
-
Success shape and status — usually
200with a schema;201only if you create something. -
Failure cases — which status, which
code, which message. -
Query params and filters — including whether an unknown filter is an empty list or a
404.
If the route creates or changes planner/exchange data, also decide what the frontend stores locally versus on the backend. The planner is device-local today; see Planner 🚧.
Routes never touch Prisma. Add a port in apps/backend/src/domain/ports/:
// apps/backend/src/domain/ports/thing.repository.ts
export type Thing = { id: number; name: string }
export type ThingRepository = {
findAll(): Promise<Thing[]>
findById(id: number): Promise<Thing | null>
}Rules:
- The port describes what is needed, not how — no Prisma, Elysia, HTTP or database types.
- Return
null(not an exception) for "not found"; the route decides that it is a404. - Return
[]for a query that matches nothing; an empty collection is not an error. - The domain layer must not import from
application/,interface/orinfrastructure/.
Implement the port in apps/backend/src/infrastructure/database/repositories/:
// prisma-thing.repository.ts
import type { Db } from '@/infrastructure/database/prisma'
import type { ThingRepository } from '@/domain/ports/thing.repository'
import { toThing } from './thing-mapper'
export function createThingRepository({ db }: { db: Db }): ThingRepository {
return {
findAll: async () => (await db.query.thing.findMany()).map(toThing),
findById: async (id) => {
const row = await db.query.thing.findFirst({ where: { id } })
return row ? toThing(row) : null
},
}
}Then register it once in repositories/index.ts:
- add
thingRepository: ThingRepositoryto theRepositoriestype; - construct it in
createRepositories(db).
createApp() spreads these repositories onto the Elysia context, so the route reads
repositories.thingRepository — it still imports only the type from domain/ports.
Column names are snake_case in SQL, camelCase in the API. Keep the mapping in a
*-mapper.tsnext to the repository, as the catalog repositories do, so the wire shape never leaks the schema.
Put shared shapes in the resource's schema file (catalog.schemas.ts, auth.schemas.ts) rather than
inline, so every route that returns the same entity stays identical:
export const thingSchema = t.Object({
id: t.Number(),
name: t.String(),
})Notes:
- Elysia validates and strips response fields against this schema. A field you forget to declare is silently omitted from the response.
-
t.Optional(...)is for absent keys;t.Union([t.String(), t.Null()])is for present-but-null. - A route failing its own response schema becomes
500 internal-error, not a client error.
Create apps/backend/src/interface/routes/things.routes.ts:
import { Elysia, t } from 'elysia'
import { HttpError } from '@/interface/errors/errors'
import type { ThingRepository } from '@/domain/ports/thing.repository'
import type { SessionRepository } from '@/domain/ports/session.repository'
import { createRequiredAuthMiddleware } from '@/interface/middleware/auth.middleware'
import { apiError } from '@/interface/middleware/error-handler'
import { thingSchema } from './thing.schemas'
// Elysia does not carry a parent `decorate` into a standalone plugin's types,
// so declare what this instance expects to find on the context.
type ThingDependencies = {
decorator: { repositories: { thingRepository: ThingRepository } }
store: {}
derive: {}
resolve: {}
}
export type ThingRoutesDeps = { sessions: SessionRepository }
export const thingRoutes = ({ sessions }: ThingRoutesDeps) =>
new Elysia<'/v1/things', ThingDependencies>({ prefix: '/v1/things' })
// Use createRequiredAuthMiddleware if every route needs a session;
// createAuthMiddleware if the session is optional.
.use(createRequiredAuthMiddleware({ resolveSession: sessions.resolve }))
// `'ApiError'` below resolves per instance — register it here too.
.model({ ApiError: apiError })
.get('/', ({ repositories }) => repositories.thingRepository.findAll(), {
response: { 200: t.Array(thingSchema) },
query: t.Object({
year: t.Optional(t.Integer()),
}),
detail: { tags: ['things'], summary: 'List things' },
})
.get(
'/:id',
async ({ repositories, params }) => {
const thing = await repositories.thingRepository.findById(params.id)
if (!thing) throw new HttpError('thing-not-found', `Thing ${params.id} not found`, 404)
return thing
},
{
response: { 200: thingSchema, 404: 'ApiError' },
params: t.Object({ id: t.Integer() }),
detail: { tags: ['things'], summary: 'Get one thing' },
},
)Why each piece exists:
| Piece | Why |
|---|---|
prefix: '/v1/things' |
/api comes from deployment; /v1 is declared here |
.use(createRequiredAuthMiddleware(...)) |
Rejects anonymous callers with 401 before the handler |
.model({ ApiError: apiError }) |
Named models are per-instance; app-level registration is not visible here |
response: { 404: 'ApiError' } |
Types the error body for the Eden client and documents it in OpenAPI |
params: t.Object({ id: t.Integer() }) |
/things/abc becomes 422 validation-error automatically |
detail: { tags, summary } |
Without it the OpenAPI operation is a bare path |
HttpError(code, message, status) |
Domain-specific code; the global handler formats the body |
Prefer the httpError factories for generic failures (httpError.notFound(),
httpError.badRequest()), and a kebab-case code for domain failures
(course-not-found, faculty-not-found, course-unit-not-found).
Do not put business logic in the route. It may validate the request, call one repository/use-case,
and choose an error code. Anything longer belongs in application/.
Add the plugin to apps/backend/src/interface/routes/api.routes.ts:
.use(thingRoutes({ sessions: deps.sessions }))If the route needs a dependency other than repositories/sessions (config, a provider), extend
createApiRoutes' deps and pass it from createApp() in main.ts.
Add the tag to the tags array in apps/backend/src/interface/plugins/openapi.ts:
{ name: 'things', description: 'Thing catalog' },The docs are dev-only: NODE_ENV === 'production' skips the plugin entirely. Locally, check the
Scalar UI at /openapi and the document at /openapi/json.
Route tests are bun:test files under apps/backend/tests/interface/routes/<group>/. They drive the
real app with createApp().handle(request) — no listener, no database.
import { describe, expect, it } from 'bun:test'
import { THINGS, createCatalogApp, registerCatalogAuthGuard } from '../support/catalog-test-utils'
const { app, get } = createCatalogApp({ things: THINGS })
describe('things routes', () => {
registerCatalogAuthGuard(app, ['/v1/things', '/v1/things/1'])
it('lists every thing', async () => {
const { status, contentType, body } = await get('/v1/things')
expect(status).toBe(200)
expect(contentType).toMatch(/^application\/json/)
expect(body).toEqual(THINGS)
})
})Cover at least:
- happy path (shape and order);
- each filter, including "unknown filter → empty list" if that is the contract;
-
404with the rightcodefor a well-formed but unknown id; -
422for a non-integer id or invalid query; -
401for anonymous and forged sessions —registerCatalogAuthGuardgenerates both.
Do not copy fixtures or fake repositories into the test file. Extend
tests/interface/routes/support/catalog-test-utils.ts (fixtures, fake repository, default it in
createCatalogApp()) or support/auth-test-utils.ts (users, sessions, config). This is what keeps
each test file assertions-only.
Run:
cd apps/backend
bun run test
bun run typecheckThe Eden Treaty client in apps/frontend/src/api/client.ts is typed from the backend App, so a new
route appears on client.v1.things without regenerating anything — if the page uses Eden.
Today most existing services still use plain fetch helpers in src/api/backend.ts against legacy
paths. Check which pattern the feature already follows before adding a call:
-
Eden —
import { client } from '@/api/client', thenclient.v1.things.get(). - SWR — wrap the call in a hook with a stable key; see the planner's data hooks.
-
Legacy fetch — add to
src/api/backend.tsonly while the un-migrated surface still needs it.
Type errors in the frontend after a backend change usually mean the frontend's tts-be type is stale:
run bun run typecheck in apps/backend (it rebuilds the contract types) and then in
apps/frontend.
In the same PR:
- add the route to the API Catalog table, in the right group;
- if it is a new group, add a heading and a row — and a planned page entry in
docs/wiki/TODO.md; - update
apps/backend/docs/endpoints.mdif the contract changed (path, shape, auth, status); - update the Glossary if you introduced a domain term;
- bump the
Last verifiedfooter of every page you touched.
- Route declared under
/v1, registered inapi.routes.ts - Request schemas (
params,query,body) reject bad input with422 - Response schema declared; errors declared as
'ApiError' - Correct middleware: required, optional, or none
- Domain code is kebab-case and the status is set at the throw site
- OpenAPI tag + summary; operation visible at
/openapi - Tests cover happy path, filters,
404,422,401 -
bun run test,bun run typecheck,bun run lint,bun run format:checkpass - Catalog + design doc updated
- No
infrastructureimport ininterface/ordomain/
- Route pattern:
apps/backend/src/interface/routes/courses.routes.ts - Test pattern:
apps/backend/tests/interface/routes/catalog/courses.test.ts - Shared fakes:
apps/backend/tests/interface/routes/support/catalog-test-utils.ts - Errors:
apps/backend/src/interface/errors/errors.tsandinterface/middleware/error-handler.ts - Layering rules: Backend layers 🚧 · Error handling 🚧
Last verified: 2026-10-27 · source: docs/wiki/04-api/adding-an-endpoint.md