# Backend Architecture The backend uses a three-layer architecture, mirroring the frontend (`frontend/docs/frontend-architecture.md`): - API layer (HTTP) - Business logic layer (BLL) - Data access layer (DAL) The goal is to keep routes thin, keep business rules testable and free of HTTP, and keep all persistence in one place. ## Layer 1: API Location: - `src/routes/` — thin route wiring: `path → middleware → wrapAsync(controller)`. - `src/api/controllers/` — one `.controller.ts` per feature; exported async handler functions `(req, res) => …`. - `src/api/http/` — request helpers (`wrapAsync`, `queryStr`, `queryNum`, `paramStr`). - `src/middlewares/` — `authenticate` (passport), `checkPermissions`, `csrf-origin`, `error-handler`, `upload`. Responsibilities: - Parse and validate the HTTP request (query, params, body, cookies, uploads). - Run middleware (auth, permissions, CSRF). - Call exactly one BLL service and shape the HTTP response (status + body). - Own multipart/upload parsing; pass parsed data (e.g. a file buffer) to the BLL. The API layer must not: - Import the DAL (`@/db/api/*`, `@/db/models/*`) — it goes through a service. - Contain tenant/role/permission/workflow rules or DTO mapping. - Run database queries. ## Layer 2: Business Logic (BLL) Location: - `src/services/` — one `.ts` (class with static methods) per feature, plus per-feature mappers/validators/helpers as needed. Infra BLL lives in `src/services/email/`. Responsibilities: - Own workflows, transactions, and coordination across repositories. - Apply tenant, role, campus, and permission rules. - Map DB records to response DTOs; validate and normalize inputs. - Accept typed inputs and return typed values/DTOs. The BLL must not: - Touch Express `req`/`res` or import `express`/middleware. (Two legacy exceptions remain — `services/file.ts` streaming and `services/auth.ts` session IP/UA + cookies — tracked by the boundary test and to be revisited.) - Import the API layer. - Render HTTP responses. ## Layer 3: Data Access (DAL) Location: - `src/db/api/` — one `*DBApi` class per entity (the repository layer). - `src/db/models/` — Sequelize models. - `src/db/migrations/`, `src/db/seeders/`, `src/db/utils.ts`, `db.config.ts`. - `src/db/api/types.ts` — DB-entity contract types (`AuthenticatedUser`, `CurrentUser`, `DbApiOptions`, …); DAL-coupled, so it stays in `db/`. Responsibilities: - Own all Sequelize queries and schema. - Return records/plain data to the BLL. The DAL must not: - Import the API layer or the BLL. (One legacy exception: `db/api/file.ts` imports `services/file` for GCloud blob deletion — tracked, to be inverted.) - Apply business rules or touch HTTP. ## Cross-cutting Location: `src/shared/` (+ ambient types in `src/types/`). - `shared/constants/` — all constants/config values (was `src/constants`). - `shared/config/` — env-driven runtime config (`index.ts` + `load-env.ts`). - `shared/errors/` — `AppError` and subclasses. - `shared/notifications/` — i18n message catalog + helpers. - `shared/logger.ts`, `shared/csv.ts`, `shared/jwt.ts`. - `shared/architecture/` — the import-boundary test. Cross-cutting code depends on no layer and may be imported by any layer. ## Import direction Allowed: ```text Route → Controller → Service (BLL) → Repository/Model (DAL) → DB ``` `shared/*` may be imported by any layer. Disallowed: ```text API (routes/controllers) → DAL (skip the BLL) BLL (services) → Express / API DAL (db) → BLL / API shared/* → any layer ``` ## Feature structure Layer-first directories, one file per feature inside each layer (only create what a feature needs): ```text src/routes/.ts src/api/controllers/.controller.ts src/services/.ts (+ mappers/validators when needed) src/db/api/.ts (repository) src/db/models/.ts src/shared/constants/.ts ``` ## Module authoring (shared factories & helpers) Most modules are assembled from shared factories/helpers — keep them that way. - **Generic CRUD entity** = three one-line config files: - `src/services/.ts` → `export default createCrudService(EntityDBApi, { notFoundCode });` - `src/api/controllers/.controller.ts` → `export default createCrudController(service, { csvFields });` - `src/routes/.ts` → `export default createCrudRouter(controller, { permission });` Factories: `services/shared/crud-service.ts`, `api/controllers/shared/crud-controller.ts`, `api/http/crud-router.ts` (generic over the repository's entity types — no casts). 23 of 26 entities use them; entities with genuinely different behavior (`users` invitations, `documents` DTO responses, `permissions` no-`globalAccess` queries) stay hand-written. - **Repository (DAL)** = entity-specific `create`/`update`/`bulkImport`/`findBy`/ `findAll`; the identical `remove`/`deleteByIds`/`findAllAutocomplete` delegate to `db/api/shared/repository.ts` (`removeRecord`, `deleteRecordsByIds`, `autocompleteByField`). - **Feature service (BLL)** = reuse shared helpers: tenant/role access in `services/shared/access.ts` (`getOrganizationId`, `getOrganizationIdOrGlobal`, `hasGlobalAccess`, `requireUserId`, `hasRoleAccess(user, roleNames)`, `campusScope(user, tenantWideRoleNames)`, `assertAuthenticatedTenantUser`, …); validation in `services/shared/validate.ts` (`clampLimit`, `nullableString`, `requiredIsoDate`/`optionalIsoDate`); transactions via `db/with-transaction.ts` (`withTransaction(fn)`); CSV import via `services/shared/csv-import.ts`; `isRecord` from `shared/object.ts`. - `getOrganizationIdOrGlobal(user)`: returns `null` for global access users (bypassing org filter) or the user's org ID; throws `ForbiddenError` if neither. - `hasGlobalAccess(user)`: returns `true` when `app_role.globalAccess === true`. - `assertAuthenticatedTenantUser(user)`: allows global access users even without an organization (useful for platform-level admins). ## Error handling Centralized — see `backend/docs/error-handling.md`. Handlers/services throw an `AppError` subclass; the terminal `error-handler` middleware turns it into the `{ message, code?, details? }` JSON body the frontend `ApiError` consumes. ## Enforcement & verification - `src/shared/architecture/import-boundaries.test.ts` enforces the import direction. Hard invariants assert zero violations; the two remaining HTTP-in-BLL edge cases and the one DAL→BLL leak are capped by ceilings that must not grow. - ESLint `no-restricted-imports` blocks (in `eslint.config.ts`) forbid the already-clean invariants at lint time (API→DAL, model/DAL/shared purity). - `npm run typecheck`, `npm run lint`, `npm test` are the verification gates; `npm test` runs the Node test runner via `tsx` (error-handler + boundary tests). ## Known remaining items - `services/file.ts` and `services/auth.ts` still depend on `req`/`res` (file streaming; session IP/UA + cookies). To be revisited with the upload subsystem. - `db/api/file.ts` → `services/file` (GCloud delete) is a DAL→BLL leak to invert (the BLL should orchestrate blob + row deletion). - `src/index.ts` remains the composition root + entry; an `app/server.ts` split is optional and deferred (deploy runs `dist/index.js`). - Repositories still hand-roll the `findAll` filter→`where` building per entity; a declarative where-builder could dedup it, deferred until the data platform stabilizes (higher-risk Sequelize typing).