Repository navigation
Backend Layers
The clean-architecture layout: what each layer owns, what it may import, and where a new piece of code belongs. Status: current.
The backend is split into four layers plus a shared kernel. Dependencies point inward. A layer may import the layers to its right in the table below, never the ones to its left.
| Layer | Path | Owns | May import |
|---|---|---|---|
| Interface | src/interface/ |
Elysia routes, middleware, wire schemas, error transport, OpenAPI |
application/, domain/, core/ — never infrastructure/
|
| Application | src/application/ |
Use-cases: orchestration and business decisions |
domain/, core/
|
| Domain | src/domain/ |
Ports (interfaces), entities, value objects — framework-free |
core/ only |
| Infrastructure | src/infrastructure/ |
Prisma repositories, SIGARRA adapters — the only code that talks outside |
domain/ports/, core/
|
| Shared kernel | src/core/ |
Identity types, config, NMEC normalisation, crypto helpers | nothing project-specific |
The composition root is src/main.ts. It is the one place allowed to know every layer: it
constructs the repositories, decorates the Elysia context, and registers the routes.
src/
├── core/ shared kernel: AuthUser, AuthConfig, normalizeNmec, randomToken
├── domain/
│ ├── ports/ CourseRepository, IdentityProvider, SessionRepository, …
│ ├── entities/ (empty today)
│ └── value-objects/ (empty today)
├── application/
│ └── use-cases/ auth/login, auth/dev-login, auth/logout, …
├── infrastructure/
│ ├── database/ contract.prisma, Prisma client, repositories/, mappers
│ └── external/ SIGARRA OIDC provider, direct credential check
├── interface/
│ ├── routes/ one file per resource + api.routes.ts
│ ├── middleware/ session, error handler
│ ├── errors/ HttpError, ErrorCode, apiError body
│ └── plugins/ OpenAPI (dev only)
├── scripts/ seeding and catalog extraction (CLI, outside the layers)
└── main.ts composition root
The rule is not a convention — it fails lint. apps/backend/.oxlintrc.json restricts imports per
layer:
| Layer | Forbidden imports |
|---|---|
domain/ |
@/application/**, @/infrastructure/**, @/interface/**
|
application/ |
@/infrastructure/**, @/interface/**
|
infrastructure/ |
@/application/**, @/interface/**, @/domain/entities/**, @/domain/value-objects/**
|
interface/ |
@/infrastructure/** |
bun run lint runs it. The messages are explicit, e.g. "Interface layer must not import
infrastructure/ directly (use dependency injection)."
Why infrastructure may not import entities or value objects: the dependency is the other way
round. Infrastructure implements a port and maps rows into the port's types; if it needed a
domain entity, the port would be leaking storage concerns. Today the catalog types live next to
their ports (Course in course.repository.ts), and entities/ / value-objects/ are empty.
Why interface may not import infrastructure: the route receives repositories through the Elysia
context (repositories.courseRepository), decorated in main.ts. Tests replace the decoration with
fakes and never boot a database.
Not every route needs a use-case. The codebase uses two patterns, deliberately.
A catalog route validates input and delegates to one port.
GET /v1/courses/:id
interface/routes/courses.routes.ts
│ repositories.courseRepository.findById(id)
▼
domain/ports/course.repository.ts (the contract)
▲
infrastructure/database/repositories/prisma-course.repository.ts
│ db.orm.public.Course.where({ id }).first()
▼
PostgreSQL
The route maps null to 404 course-not-found; the repository maps rows to the port type; the
shared course-mapper.ts resolves the FacultyCourse join. There is no business rule to orchestrate,
so a use-case would be a pass-through.
Auth has decisions to make, so it lives in application/use-cases/auth/:
POST /v1/auth/dev-login
interface/routes/auth/auth.routes.ts
│ devLogin({ username, password })
▼
application/use-cases/auth/dev-login.ts
│ authenticate → find/upsert user → issue session
├──▶ domain/ports/dev-authenticator.ts ◀── infrastructure/external/sigarra-direct-auth.ts
├──▶ domain/ports/user.repository.ts ◀── infrastructure/database/repositories/user.repository.ts
└──▶ domain/ports/session.repository.ts ◀── infrastructure/database/repositories/session.repository.ts
complete-login.ts is the clearest example: verify state, exchange the code, upsert the user, issue
a session, resolve the redirect. None of that belongs in a route, and none of it needs Elysia.
Rule of thumb: if the route would contain an if that is not about HTTP (status codes, headers,
cookies), move it to application/.
| You are adding… | Layer | Example |
|---|---|---|
| A route, a query/body schema, a status code | interface/routes/ |
courses.routes.ts |
| A wire response shape shared by routes | interface/routes/*.schemas.ts |
catalog.schemas.ts |
| A middleware (session, CORS, error shape) | interface/middleware/ |
auth.middleware.ts |
| A domain rule that spans repositories | application/use-cases/ |
complete-login.ts |
| A new entity or repository contract |
domain/ports/ (and later entities/) |
course.repository.ts |
| A Prisma query or external HTTP adapter | infrastructure/ |
prisma-course.repository.ts |
| A DB↔API mapping | infrastructure/database/repositories/ |
course-mapper.ts |
| An env var or config type |
core/config/ + .env.schema
|
core/config/auth.ts |
| A type every layer needs | core/ |
core/auth/user.ts |
| Seeding / catalog extraction | src/scripts/ |
extract-catalog.ts |
-
Interface: no SQL, no Prisma, no business decisions, no direct
process.envreads outside config; keep repositories on the context. -
Application: no Elysia types (
Context,t), no HTTP status codes, noRequest/Response. Throw domain errors or return result objects; let the interface translate them. - Domain: no framework imports at all — no Elysia, no Prisma, no Node HTTP. If it needs a clock, an id generator or a fetcher, define a port.
- Infrastructure: no route logic, no response shaping for a specific endpoint. It implements a port and returns the port's types.
- Core: no imports from the four layers. It is a kernel, not a utility dumping ground.
HttpError and ErrorCode live in interface/errors/ because a status code and a wire body are
transport concerns. A repository does not throw HttpError; it returns null or [] and lets
the route choose 404.
When the domain needs to fail on its own terms, it should throw a domain error from domain/errors/
(e.g. FacultyNotFound) and the interface error handler grows an instanceof branch. That directory
does not exist yet; today every failure is either a null/[] from a repository or an HttpError
thrown by a route or use-case.
See Error handling 🚧 for the full code map and the 400 vs 422 rule.
Route tests use the real app (createApp().handle(request)) but inject fake repositories through
createApp({ repositories }). No database, no network. That only works because the interface depends
on ports, not on Prisma. The fakes live in tests/interface/routes/support/.
-
catalog-test-utils.ts— catalog fixtures, fake repositories,createCatalogApp() -
auth-test-utils.ts— user, sessions, config, OIDC/dev-auth fakes
The runbook is in Adding an endpoint.
- Architecture overview — components and trust boundaries
- Adding an endpoint — the step-by-step
- API Catalog — the live routes
- Request lifecycle 🚧 · Error handling 🚧 · Testing 🚧
Last verified: 2026-10-27