Architecture
The Primebrick Backend is an Express.js application structured around a
strict Controller–Service boundary: transport adapters live under
src/controllers/, while domain modules under src/modules/ own
entities, DAL, and transport-agnostic services. No business logic is
allowed in controllers, and no transport types are allowed in services.
Source layout
Code
Module structure
Each module under src/modules/ follows the same pattern:
| Layer | Responsibility |
|---|---|
| Entity | TypeScript class defining the DB table shape (*_entity.ts) |
| Metadata | Entity metadata for the DAL (*.meta.ts) |
| DAL | Data access layer repository (*_dal.ts) — imported only by services |
| Service | Business logic (services/*.service.ts) — transport-agnostic, no req/res |
| Middleware | Auth and RBAC middleware (*.middleware.ts) |
Controllers do not live inside modules. An HTTP endpoint for the
customers domain lives in src/controllers/http/customers.router.ts;
a NATS subscriber for the proxy domain lives in
src/controllers/nats-sub/service-lifecycle-subscriber.ts. The directory
name encodes the transport, the file name encodes the domain.
The Controller–Service contract
This is the core architectural rule of the backend. It exists so that the same operation can be invoked over HTTP and reached by NATS pub/sub workflows — only the controller file changes, the service is untouched.
Legal transport matrix (decided 2026-10-09)
| Direction | Transport | Pattern |
|---|---|---|
| FE → BE | HTTP | req/res + SSE — the ONLY public attack surface |
| BE → US | HTTP via /ws/:code/* proxy | req/res, no BE logic added |
| BE → US | NATS pub/sub | fire-and-forget (e.g. emailsender.send) |
| US → BE / US → US | NATS pub/sub | fire-and-forget ONLY; sync-looking needs use correlation replies (x.call → x.response.<requestId>) |
| any → any | NATS event bus | choreography: entity.<entity>.<action> subjects; identical replicas share a queue group (one copy, round-robin), different services each get a copy |
NATS request-reply (NatsClient.request / subscribeRequest) is
DEPRECATED and must not be used in new code. Microservices must never
call the BE over HTTP — the only known legacy exception being migrated is
the AI MCP client.
A controller MAY:
- authenticate (session JWT / API key / gateway-resolved NATS headers)
- validate the incoming payload (Zod / shape checks)
- call exactly one service method
- shape the transport response: HTTP status + RFC 7807 body, NATS reply envelope, or JetStream ack/nak
- own the root
try/catchthat maps service errors to the transport - use shared transport helpers:
runEntityWrite/entityWriteBody(write-transaction orchestration wrapping the one service call),rbacHandler,asyncHandler,validateBody,validateQuery
A controller MUST NOT:
- import the DAL or repositories directly
- contain domain rules (existence policy, reserved checks, value coercion)
- loop over entities applying per-item business logic
- build response payloads field-by-field — services return the formed
snake_caseshape; the controller only serializes it - invent error codes — errors come from the service layer (
ApiError); the controller only maps them to transport
A service is a pure (validatedInput, ctx) → result | throw:
- zero
req/res/NATS-message imports — no transport types at all - the same service method must be callable identically from
http/,nats-req/, andnats-sub/controllers
Code
HTTP layer
The src/http/ directory provides shared HTTP infrastructure:
define-route.ts— typed route definition helper with Zod validationasync-handler.ts— wraps async route handlers for error propagationerror-handler.ts— global error handler producing RFC 7807 responsesapi-errors.ts— stable error codes withimpactfield for the frontendvalidation.ts— request body and query parameter validationprotected-router.ts— router factory that enforces auth + RBAC
Request flow
Every request to a protected endpoint passes through the same chain. The
makeProtectedRouter() factory enforces a secure-first policy: any route
registered without an rbacHandler(...) declaration is automatically prepended
with a defaultDeny handler that returns 403 with
ROUTE_PERMISSION_NOT_DECLARED. A route that exists in code but forgot to
declare its permission is never accidentally exposed.
makeProtectedRouter() — secure-first router factory
Code
registerRoutes() — declarative route table
For controllers with many endpoints, registerRoutes() lets you describe the
whole router as a single scannable table at the top of the file instead of a
sequence of router.<verb>(...) calls. Each entry chains
permission → middlewares → handler in that fixed order.
Code
asyncHandler() — async error propagation
Express 4 does not catch rejected promises from async handlers. asyncHandler
wraps the handler so a rejected promise (or a thrown ApiError) is forwarded to
next and reaches the centralized errorHandler.
Code
validateBody() / validateQuery() — Zod validation
Both helpers parse the request body/query against a Zod schema. On failure they
respond 400 with a RFC 7807 body whose extra.issues mirrors Zod's issue shape
(path, code, message). On success they replace req.body/req.query
with the parsed (and coerced) value.
Code
ApiError — RFC 7807 error base class
Every error thrown in a route handler or service extends ApiError. The
centralized errorHandler calls err.toResponse() and emits the JSON body with
the matching HTTP status. The internal_code is the stable string the frontend
branches on; severity drives the toast style.
Code
| Class | Status | Default internal_code | severity |
|---|---|---|---|
NotFoundError | 404 | NOT_FOUND | MEDIUM |
ValidationError | 400 | VALIDATION_ERROR | MEDIUM |
UnprocessableEntityError | 422 | UNPROCESSABLE_ENTITY | MEDIUM |
UnauthorizedError | 401 | UNAUTHORIZED | MEDIUM |
ForbiddenError | 403 | FORBIDDEN | MEDIUM |
AuthConfigNotLoadedError | 500 | AUTH_CONFIG_NOT_LOADED | CRITICAL |
ApiError (base) | (caller-set) | (caller-set) | HIGH |
The errorHandler also maps database-unavailable errors (ECONNREFUSED, PG
57P03 cannot_connect_now, etc.) to 503 with severity: CRITICAL, and any
other uncaught error to 500 with severity: HIGH.
A complete module walkthrough
The customers module is the canonical reference for a domain module. It owns
its entity, DAL, service, DTOs, and metadata — while its HTTP router lives in
src/controllers/http/ as a thin transport adapter. Every layer has a single
responsibility.
1. Entity (customer_entity.ts)
A plain TypeScript class decorated with @Entity, @Key, @Unique,
@CloneField, and @AuditTrail, composed on the DAL capability mixins
(Auditable() / Deletable()) so the shared audit/soft-delete columns are
declared once in the library — never per entity. The class IS the DB schema —
there is no separate SQL file to keep in sync. See
the DAL entities guide for every decorator and mixin.
Code
Hard-delete entities compose Auditable(EntityBase) only; AuditTrail() stays
an opt-in on the concrete class.
2. Service (customers.service.ts)
The service owns the business logic. It is request-context-free: it takes
plain parameters and reads the actor from AsyncLocalStorage (requireActor()
via the DAL) — it never touches req/res. Errors are thrown as ApiError
subclasses so the centralized errorHandler can convert them to RFC 7807.
DAL rule: the ONLY public DB API is
initDal()fromsrc/db/dal-gateway.ts, which returns the governedDalfrom@primebrick/dal-pg(schema-domain guard +withCacheentity caching viarepoDecorator).initDal().getPool()is reserved for migration/patch tooling only — application code must never touch it. (The example below shows the legacy per-entity DAL wrapper; new services usemakeEntityServiceinstead.)
Code
3. Controller (src/controllers/http/customers.router.ts)
The controller is a thin transport adapter. It contains NO business logic —
it wires Zod-validated request data into the service and shapes the HTTP
response. Every route declares its permission via rbacHandler(...); the
secure-first makeProtectedRouter() would otherwise inject a 403
default-deny. Note that it never imports customers_dal.ts — the DAL is
reachable only through the service.
Code
4. Mounting (src/index.ts)
Controllers are mounted by mountModules(app) at startup — it imports the
routers from src/controllers/http/. The /api/v1/health endpoint and the
OpenAPI docs router are mounted before the guard so they stay public.
NATS controllers (controllers/nats-req/, controllers/nats-sub/) are
started separately at boot, once the NATS connection is up.
Code
OpenAPI
The backend generates its OpenAPI spec from code:
src/openapi/openapi.ts— defines the system API specsrc/openapi/router.ts— mounts the OpenAPI JSON endpointsrc/openapi/aggregated-router.ts— aggregates the backend spec with microservice specs registered via NATS
The docs site fetches these specs at build time to populate the API Catalog.
Service registry
The backend subscribes to microservice lifecycle events over NATS:
When a microservice registers, the backend persists its metadata and exposes its endpoints through the aggregated OpenAPI router and the reverse proxy module.
Data access, audit, and events
One DAL API: application code uses getDal() from @primebrick/dal-pg
(singleton initialized in src/db/dal-gateway.ts). getPool() is
infra-internal — never import it outside src/db/.
Three distinct concerns, often conflated:
- Audit trail — persistent
{table}_auditrows written by dal-pg'sAuditPorton every write/delete/restore. Same contract in the BE and in every microservice schema. Audit is opt-in per write call ({ actor, audit }) — omitting it silently skips the row; tx-bound writes must use an adapter bound to the tx repo (new EntityAuditPort(repo)), not the root repo. - Entity-changed event — the event-sourcing signal emitted in parallel
to the write (
entity.<entity>.<action>on NATS + a Redis marker), so any service or subsystem can intercept lifecycle changes without polling. - Collaboration — a consumer of the entity-changed event: the BE bridges those markers into SSE so connected FE clients see changes in real time. It is not part of audit.
Translations are per-module: public.translations holds the global
app.* keys, system.translations holds system.* keys, and each
microservice owns <schema>.translations for its module's keys. The BE's
central translations gateway manages CRUD over all module tables; modules
read app.* globally and pull their own module table down on first load.
Key prefixes are authoritative — app.* must never appear in
system.translations and vice versa.
Next steps
- Authentication — the Casdoor OIDC flow, JWT verification, passkeys, and invitations.
- RBAC — the wildcard permission system,
rbacHandler, and role mappings. - Collaboration — optimistic locking, field-level audit, and visual merge.
- DAL entities & decorators — the entity decorators used by every module.