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.
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);
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.
| Resource | Base path | Notes |
|---|---|---|
| Courses | /courses | |
| Lessons | /courses/:courseId/lessons (list/create), /lessons/:id (update/delete) | |
| Lesson content | /lessons/:lessonId/content | Separate from lesson structure — different owner, different repository file |
| Corrections | /lessons/:lessonId/corrections (suggest/list), /corrections/:id (review) | |
| Course status | /courses/:courseId/status | Publish/unpublish |
| Course assets | /courses/:courseId/assets, /assets/:id |
4. HTTP verbs and status codes
| Verb | Use for | Success code |
|---|---|---|
GET | Read one or many | 200 |
POST | Create | 201 |
PATCH | Partial update | 200 |
DELETE | Remove | 204 (no body) |
PUT is not used — every update in this API is partial.
| Situation | Code |
|---|---|
| Validation failure | 400 |
| Not authenticated (missing/invalid token) | 401 |
| Authenticated but not permitted (e.g. not the course owner) | 403 |
| Resource doesn't exist | 404 |
| State conflict (e.g. already reviewed, original text no longer present) | 409 |
| Unhandled server error | 500 |
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.
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)
| Area | Convention |
|---|---|
| Variables, functions | camelCase |
| Classes, interfaces, types | PascalCase |
| Database tables/columns | snake_case |
| Files | kebab-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)
-
requireAuthcovers it (mounted globally — confirm nothing bypasses it); ownership/permission checked explicitly in the service layer if needed (Section 8) - Errors use
@osl/shared'sApiError, with a code from the existingApiErrorCodeset - Response fields match the database's
snake_casenaming (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