Skip to content

Backend Layers

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

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.

The layers

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 dependency rule, enforced

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.

Two request shapes

Not every route needs a use-case. The codebase uses two patterns, deliberately.

1. Thin read: route → port → repository

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.

2. Orchestrated write / multi-step flow: route → use-case → ports

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

Where new code goes

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

What each layer must not do

  • Interface: no SQL, no Prisma, no business decisions, no direct process.env reads outside config; keep repositories on the context.
  • Application: no Elysia types (Context, t), no HTTP status codes, no Request/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.

Errors and the boundary

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.

Tests follow the same boundaries

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.

See also


Last verified: 2026-10-27

Clone this wiki locally