Skip to content

Adding an Endpoint

Francisca Portugal edited this page Oct 27, 2026 · 1 revision

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.

0. Decide the contract

Before writing code, write down:

  • Method and path — under /v1, plural resource nouns, no /api prefix (the deployment adds it).
  • Auth — public, session-required, or session-optional.
  • Success shape and status — usually 200 with a schema; 201 only 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 🚧.

1. Domain port (only if you need new data)

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 a 404.
  • Return [] for a query that matches nothing; an empty collection is not an error.
  • The domain layer must not import from application/, interface/ or infrastructure/.

2. Prisma implementation

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:

  1. add thingRepository: ThingRepository to the Repositories type;
  2. 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.ts next to the repository, as the catalog repositories do, so the wire shape never leaks the schema.

3. Wire schemas

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.

4. The route

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/.

5. Register it

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.

6. Tag it in OpenAPI

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.

7. Tests

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;
  • 404 with the right code for a well-formed but unknown id;
  • 422 for a non-integer id or invalid query;
  • 401 for anonymous and forged sessions — registerCatalogAuthGuard generates 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 typecheck

8. Frontend

The 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', then client.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.ts only 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.

9. Update the docs

In the same PR:

  1. add the route to the API Catalog table, in the right group;
  2. if it is a new group, add a heading and a row — and a planned page entry in docs/wiki/TODO.md;
  3. update apps/backend/docs/endpoints.md if the contract changed (path, shape, auth, status);
  4. update the Glossary if you introduced a domain term;
  5. bump the Last verified footer of every page you touched.

Definition of done

  • Route declared under /v1, registered in api.routes.ts
  • Request schemas (params, query, body) reject bad input with 422
  • 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:check pass
  • Catalog + design doc updated
  • No infrastructure import in interface/ or domain/

Reference

  • 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.ts and interface/middleware/error-handler.ts
  • Layering rules: Backend layers 🚧 · Error handling 🚧

Last verified: 2026-10-27 · source: docs/wiki/04-api/adding-an-endpoint.md

Clone this wiki locally