185 lines
7.5 KiB
Markdown
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).
|