40227-vm/backend/docs/backend-architecture.md
2026-06-10 18:27:19 +02:00

185 lines
7.5 KiB
Markdown

# 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 `<feature>.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 `<feature>.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/<feature>.ts
src/api/controllers/<feature>.controller.ts
src/services/<feature>.ts (+ mappers/validators when needed)
src/db/api/<feature>.ts (repository)
src/db/models/<feature>.ts
src/shared/constants/<feature>.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/<e>.ts``export default createCrudService(EntityDBApi, { notFoundCode });`
- `src/api/controllers/<e>.controller.ts``export default createCrudController(service, { csvFields });`
- `src/routes/<e>.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).