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.tsper 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 insrc/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/resor importexpress/middleware. (Two legacy exceptions remain —services/file.tsstreaming andservices/auth.tssession 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*DBApiclass 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 indb/.
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.tsimportsservices/filefor 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 (wassrc/constants).shared/config/— env-driven runtime config (index.ts+load-env.ts).shared/errors/—AppErrorand 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>.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 (usersinvitations,documentsDTO responses,permissionsno-globalAccessqueries) stay hand-written. -
Repository (DAL) = entity-specific
create/update/bulkImport/findBy/findAll; the identicalremove/deleteByIds/findAllAutocompletedelegate todb/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 inservices/shared/validate.ts(clampLimit,nullableString,requiredIsoDate/optionalIsoDate); transactions viadb/with-transaction.ts(withTransaction(fn)); CSV import viaservices/shared/csv-import.ts;isRecordfromshared/object.ts.getOrganizationIdOrGlobal(user): returnsnullfor global access users (bypassing org filter) or the user's org ID; throwsForbiddenErrorif neither.hasGlobalAccess(user): returnstruewhenapp_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.tsenforces 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-importsblocks (ineslint.config.ts) forbid the already-clean invariants at lint time (API→DAL, model/DAL/shared purity). npm run typecheck,npm run lint,npm testare the verification gates;npm testruns the Node test runner viatsx(error-handler + boundary tests).
Known remaining items
services/file.tsandservices/auth.tsstill depend onreq/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.tsremains the composition root + entry; anapp/server.tssplit is optional and deferred (deploy runsdist/index.js).- Repositories still hand-roll the
findAllfilter→wherebuilding per entity; a declarative where-builder could dedup it, deferred until the data platform stabilizes (higher-risk Sequelize typing).