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

7.5 KiB

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:

Route → Controller → Service (BLL) → Repository/Model (DAL) → DB

shared/* may be imported by any layer. Disallowed:

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):

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>.tsexport default createCrudService(EntityDBApi, { notFoundCode });
    • src/api/controllers/<e>.controller.tsexport default createCrudController(service, { csvFields });
    • src/routes/<e>.tsexport 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.tsservices/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).