Skip to main content

API Conventions

This document describes the conventions used across the backend. Read this before writing your first route in a new service, since retrofitting conventions onto several already-built services is far more painful than following the real pattern from day one.

Why this document exists

With 6 people and 9 independent backend services, the risk isn't that any single endpoint is badly built — it's that different services (or different people within the same service) each invent a slightly different shape, and integrating them becomes its own project. This doc removes that decision from every individual PR by describing what's actually agreed and in use.


1. Architecture — independent services, not modules in one app

The backend consists of separate microservices — course_service, user_service, trust_service, assessment_service, discovery_service, moderation_service, notification_service, progress_service, and conversation_service — each its own deployable, each with its own package.json, own Prisma schema/client, own port, and its own Postgres schema namespace within one shared Supabase instance.

An api_gateway sits in front of all of them and routes requests through to the right service. The frontend only ever talks to the gateway.

Folder structure — per service, layered

Each service follows the same internal layout:

backend/services/<service_name>/
src/
app.ts # Express app: middleware, route mounting
index.ts # entry point
controllers/ # HTTP request/response shaping, Zod validation
services/ # business logic, ownership checks
repository/ # the ONLY layer allowed to touch Prisma
routes/ # route definitions
types/ # Zod schemas + inferred types
utils/ # cross-cutting helpers (errors, logger,
# small HTTP clients to other services)
tests/ # node:test files
prisma/
schema.prisma
prisma.config.ts

A service's app.ts is the only file every route-adding PR touches, and only to mount a router — the same trivial-mount principle the original plan described, just per-service instead of per-module:

app.use(course_routes);
app.use(lesson_routes);
app.use(correction_routes);
warning

Never query Prisma outside the repository/ layer. This is enforced by convention, not tooling — code review is where this gets caught.


2. Base path and versioning

Each service's routes are mounted at its own root (e.g. /courses, /lessons/:lessonId/corrections), and the gateway exposes them under /api/<service-name>, e.g.:

https://api.example.com/api/courses/courses
https://api.example.com/api/courses/courses/:courseId/lessons
https://api.example.com/api/courses/lessons/:lessonId/corrections

3. Naming endpoints

Resources are plural nouns. Nesting reflects real ownership. In practice, nesting has gone slightly deeper than one level where the relationship genuinely warrants it (e.g. /courses/:courseId/lessons/reorder) — treat "one level" as a strong default, not an absolute rule.

ResourceBase pathNotes
Courses/courses
Lessons/courses/:courseId/lessons (list/create), /lessons/:id (update/delete)
Lesson content/lessons/:lessonId/contentSeparate from lesson structure — different owner, different repository file
Corrections/lessons/:lessonId/corrections (suggest/list), /corrections/:id (review)
Course status/courses/:courseId/statusPublish/unpublish
Course assets/courses/:courseId/assets, /assets/:id

4. HTTP verbs and status codes

VerbUse forSuccess code
GETRead one or many200
POSTCreate201
PATCHPartial update200
DELETERemove204 (no body)

PUT is not used — every update in this API is partial.

SituationCode
Validation failure400
Not authenticated (missing/invalid token)401
Authenticated but not permitted (e.g. not the course owner)403
Resource doesn't exist404
State conflict (e.g. already reviewed, original text no longer present)409
Unhandled server error500

5. Request and response shape

Success responses

Every success response is wrapped:

{ "success": true, "data": { "id": "...", "title": "..." } }

List/paginated endpoints include pagination fields alongside data, rather than in a nested meta object:

{
"success": true,
"data": [ { "id": "...", "title": "..." } ],
"total": 143,
"skip": 0,
"take": 20
}

Error responses

Every error, from every service, uses @osl/shared's ApiError shape:

{ "error": { "code": "VALIDATION_ERROR", "message": "title is required" } }

code is one of a fixed, shared set defined in @osl/shared (ApiErrorCode): the four UNAUTHENTICATED_* variants (missing/malformed/ expired/invalid token), plus FORBIDDEN, NOT_FOUND, VALIDATION_ERROR, INTERNAL_ERROR. Services do not invent their own codes — a service-specific error (e.g. "course not found") maps onto the closest general code (NOT_FOUND), with the specific detail carried in message.

There is currently no fields array identifying which specific input field failed validation — only message, which may describe the failing field in prose. Adding a fields?: string[] to ApiErrorBody is a reasonable future improvement, not yet implemented.

Common mistake

Returning 200 with a { success: false } body for errors. Use real HTTP status codes — the frontend's apiFetch branches on status code and reads error.message from the body, not a success flag.

Field naming

Response bodies mirror the database's snake_case field names directly (course_id, last_edited_by_id, created_at) — not converted to camelCase. This differs from a common REST convention, but matches how Prisma is configured here (schema fields are declared in snake_case with no @map, so the generated client's fields are already snake_case, and nothing converts them on the way out).

Request bodies for newer endpoints (e.g. corrections) accept camelCase keys (originalText, suggestedText), validated via a Zod schema local to that endpoint. This is a deliberate boundary: the wire format for incoming requests uses the code-level naming convention (see Section 8), while stored/returned data mirrors the database directly.


6. Pagination and filtering

Where implemented (e.g. course listing), pagination uses skip/take query params, with total returned in the response body:

GET /courses?skip=0&take=20&language_code=fr&status=published

There is no shared, generic parseListQuery middleware yet — each endpoint that needs pagination implements it directly against its own filter set. Worth extracting into a shared utility if a third or fourth endpoint needs the same pattern.


7. Validation

Each controller defines the Zod schemas it needs locally, in the same file or a colocated types/*.types.ts file — not a separate *.schema.ts file per resource. Schemas are deliberately narrower than the full database-row schema where a specific endpoint only accepts a subset of fields (e.g. lesson-structure endpoints never accept content, since that field belongs to a different owner/file).

// controllers/correction.controller.ts
const suggestCorrectionBodySchema = z.object({
originalText: z.string().min(1),
suggestedText: z.string().min(1),
});

Route params are also parsed through Zod (not accessed as raw strings), since Express types every param as string | string[] | undefined:

const lessonIdParamSchema = z.object({ lessonId: z.string().uuid() });

A failed validation is caught in the controller and returns the standard error shape (Section 5) with a 400 and VALIDATION_ERROR.


8. Authentication and authorization

Authentication is one shared middleware, requireAuth (from @osl/shared), mounted once in each service's app.ts, after that service's own /alive and /health routes and before every other router. It verifies the Supabase JWT and attaches req.user. Every business route in every service requires it — there are no other public routes.

Authorization (ownership, permission) lives in the service layer, as an explicit check at the top of whichever service method needs it:

async function assertIsCourseOwner(courseId: string, requestingUserId: string) {
const ownerId = await getCourseOwnerId(courseId);
if (!ownerId) throw new ApiError(404, "NOT_FOUND", "Course not found");
if (ownerId !== requestingUserId) throw new ApiError(403, "FORBIDDEN", "...");
}

9. Naming conventions (code-level)

AreaConvention
Variables, functionscamelCase
Classes, interfaces, typesPascalCase
Database tables/columnssnake_case
Fileskebab-case.ts, React components PascalCase.tsx

Known migration debt: services/files written early in Sprint 1 (course.*, lesson.* in course_service) still use snake_case for variables and functions, predating this convention being finalized. Newer files (correction.*) use camelCase throughout. A refactor branch to bring the older files in line is planned before the end of Sprint 1 — until then, expect both styles to coexist within course_service.


10. Adding a new endpoint — checklist

  • Path follows the naming table (Section 3)
  • Correct verb and success status code (Section 4)
  • Request body and route params validated via local Zod schemas (Section 7)
  • requireAuth covers it (mounted globally — confirm nothing bypasses it); ownership/permission checked explicitly in the service layer if needed (Section 8)
  • Errors use @osl/shared's ApiError, with a code from the existing ApiErrorCode set
  • Response fields match the database's snake_case naming (Section 5)
  • Endpoint documented in this file's routing table (Section 3) and in the owning service's own README.md
  • Test coverage: at minimum, the success case and the main failure case(s) — using the dependency-injection pattern (constructor-injected deps with real defaults) so tests don't rely on mocking live module bindings