PrimeBrickPrimeBrick
  • Docs
  • Contact
  • MIT License
  • Documentation
  • MCP Server
  • API Catalog
  • Services
  • Libraries
PrimeBrickPrimeBrick

© 2026 PrimeBrick. MIT License. v3.11.2

github
Backend
    Roles and PermissionsWorking Together on the Same RecordManaging ModulesSigning In and MFA
    Generated
    Manual
    Developer Guide
      OverviewArchitectureAPI ConventionsEntity metadata contractEntity CRUD PipelineEntities and CRUD conventionsModule Navigation MetadataService RegistryMCP GatewayThe Service ProxyRotating Secrets EngineTranslationsAuthenticationRBACEnvironment ConfigurationCollaboration & Visual Merge
Frontend
Microservices
powered by Zudoku
Developer Guide

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
src/ controllers/ Transport adapters — no business logic http/ HTTP routers (Express) nats-req/ DEPRECATED — NATS request-reply is banned (see transport matrix) nats-sub/ NATS pub/sub + JetStream subscribers, cron/poll jobs db/ Database connection and patch runner domain/ Shared domain types and utilities http/ HTTP infrastructure: error handling, validation, route helpers i18n/ Internationalization lib/ Shared library utilities modules/ Domain modules (auth, customers, mcp, proxy, system) openapi/ OpenAPI spec generation and aggregated router

Module structure

Each module under src/modules/ follows the same pattern:

LayerResponsibility
EntityTypeScript class defining the DB table shape (*_entity.ts)
MetadataEntity metadata for the DAL (*.meta.ts)
DALData access layer repository (*_dal.ts) — imported only by services
ServiceBusiness logic (services/*.service.ts) — transport-agnostic, no req/res
MiddlewareAuth 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)

DirectionTransportPattern
FE → BEHTTPreq/res + SSE — the ONLY public attack surface
BE → USHTTP via /ws/:code/* proxyreq/res, no BE logic added
BE → USNATS pub/subfire-and-forget (e.g. emailsender.send)
US → BE / US → USNATS pub/subfire-and-forget ONLY; sync-looking needs use correlation replies (x.call → x.response.<requestId>)
any → anyNATS event buschoreography: 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/catch that 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_case shape; 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/, and nats-sub/ controllers
Code
// Controller (src/controllers/http/customers.router.ts) — transport only const get = asyncHandler(async (req, res) => { const customer = await customersService.getCustomer(uuidParam(req)); res.json(customer); // NotFoundError → errorHandler → RFC 7807 }); // Service (src/modules/customers/customers.service.ts) — business logic async getCustomer(uuid: string) { const found = await this.getDal().findByUUID(CustomerEntity, uuid); if (!found) throw new NotFoundError(`Customer ${uuid} not found`); return found; }

HTTP layer

The src/http/ directory provides shared HTTP infrastructure:

  • define-route.ts — typed route definition helper with Zod validation
  • async-handler.ts — wraps async route handlers for error propagation
  • error-handler.ts — global error handler producing RFC 7807 responses
  • api-errors.ts — stable error codes with impact field for the frontend
  • validation.ts — request body and query parameter validation
  • protected-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
import { makeProtectedRouter } from "../../http/protected-router.js"; import { rbacHandler } from "../../modules/auth/rbac.middleware.js"; import { Permission } from "@primebrick/sdk"; const router = makeProtectedRouter(); // Declared route — rbacHandler tags the handler with PERMISSION_DECLARED. router.get( "/api/v1/health-check", rbacHandler([Permission.PUBLIC]), (_req, res) => res.json({ ok: true }), ); // Forgotten declaration — the factory injects defaultDenyHandler → 403. router.get("/api/v1/whoops", (_req, res) => res.json({ leak: true })); // → 403 { internal_code: "ROUTE_PERMISSION_NOT_DECLARED" }

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.&lt;verb&gt;(...) calls. Each entry chains permission → middlewares → handler in that fixed order.

Code
import { registerRoutes } from "../../http/define-route.js"; import { validateBody, validateQuery } from "../../http/validation.js"; import { rbacHandler } from "../../modules/auth/rbac.middleware.js"; import { Permission } from "@primebrick/sdk"; import { z } from "zod"; registerRoutes(router, [ { method: "get", path: "/api/v1/entities/customer/list", permission: rbacHandler([Permission.CUSTOMER_READ_ALL]), middlewares: [validateQuery(CustomerListQuerySchema)], handler: list, }, { method: "post", path: "/api/v1/entities/customer", permission: rbacHandler([Permission.CUSTOMER_CREATE_SINGLE]), middlewares: [validateBody(CustomerCreateBodySchema)], handler: create, }, ]);

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
import { asyncHandler } from "../../http/async-handler.js"; const list = asyncHandler(async (req, res) => { const result = await service.listCustomers(req.query); res.json(result); });

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
import { z } from "zod"; import { validateBody } from "../../http/validation.js"; const CustomerCreateBodySchema = z.object({ code: z.string().min(1).max(20), email: z.string().email().optional(), status: z.enum(["ACTIVE", "INACTIVE"]), }); // In the route definition: // middlewares: [validateBody(CustomerCreateBodySchema)]

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
import { NotFoundError, ValidationError, ForbiddenError, UnauthorizedError } from "../../http/api-errors.js"; // 404 — Resource not found throw new NotFoundError(`Customer ${uuid} not found`); // 400 — Validation error (with a custom internal_code) throw new ValidationError("Request validation failed", { internal_code: "VALIDATION_ERROR" }); // 403 — Forbidden (extra.issues is read by the FE) throw new ForbiddenError("Insufficient permissions", { internal_code: "RBAC_PERMISSION_DENIED", extra: { issues: [{ kind: "missing_any_of_permissions", required: ["customer.read.all"] }] }, }); // 401 — Unauthorized throw new UnauthorizedError("Missing or invalid Authorization header");
ClassStatusDefault internal_codeseverity
NotFoundError404NOT_FOUNDMEDIUM
ValidationError400VALIDATION_ERRORMEDIUM
UnprocessableEntityError422UNPROCESSABLE_ENTITYMEDIUM
UnauthorizedError401UNAUTHORIZEDMEDIUM
ForbiddenError403FORBIDDENMEDIUM
AuthConfigNotLoadedError500AUTH_CONFIG_NOT_LOADEDCRITICAL
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
import type { IExposableEntity, IClonableEntity } from "@primebrick/dal-pg"; import { Entity, Column, Key, Unique, CloneField, AuditTrail, EntityBase, Auditable, Deletable, } from "@primebrick/dal-pg"; @Entity("customers") @AuditTrail() export class CustomerEntity extends Deletable(Auditable(EntityBase)) // audit + soft-delete columns implements IExposableEntity, IClonableEntity { @Key() id: bigint; @Unique() uuid: string; @Column({ length: 20, nullable: false }) code: string; first_name?: string; last_name?: string; @Column({ length: 320 }) email?: string; @Column({ nullable: false }) status: "ACTIVE" | "INACTIVE"; @CloneField() cloned_from?: string; }

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() from src/db/dal-gateway.ts, which returns the governed Dal from @primebrick/dal-pg (schema-domain guard + withCache entity caching via repoDecorator). 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 use makeEntityService instead.)

Code
// infra-internal — services use makeEntityService; module DALs call initDal() import { CustomersDal } from "./customers_dal.js"; import { NotFoundError } from "../../http/api-errors.js"; export class CustomersService { private dal: CustomersDal | null = null; private getDal(): CustomersDal { if (this.dal) return this.dal; this.dal = new CustomersDal(getPool()); return this.dal; } async getCustomer(uuid: string) { const found = await this.getDal().findByUUID(CustomerEntity, uuid); if (!found) throw new NotFoundError(`Customer ${uuid} not found`); return found; } async createCustomer(body: CustomerCreateBody) { // The actor is read from ALS inside the DAL — no need to pass req.user. return await this.getDal().add(CustomerEntity, body, { actor: requireActor() }); } }

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
import { makeProtectedRouter } from "../../http/protected-router.js"; import { registerRoutes } from "../../http/define-route.js"; import { asyncHandler } from "../../http/async-handler.js"; import { validateBody, validateQuery } from "../../http/validation.js"; import { rbacHandler } from "../../modules/auth/rbac.middleware.js"; import { Permission } from "@primebrick/sdk"; import { CustomersService } from "../../modules/customers/customers.service.js"; import { CustomerCreateBodySchema, CustomerListQuerySchema } from "../../modules/customers/dto.js"; export function customersRouter() { const router = makeProtectedRouter(); const service = new CustomersService(); const list = asyncHandler(async (req, res) => { const result = await service.listCustomers(req.query as any); res.json(result); }); const create = asyncHandler(async (req, res) => { const created = await service.createCustomer(req.body as any); res.status(201).json(created); }); registerRoutes(router, [ { method: "get", path: "/api/v1/entities/customer/list", permission: rbacHandler([Permission.CUSTOMER_READ_ALL]), middlewares: [validateQuery(CustomerListQuerySchema)], handler: list, }, { method: "post", path: "/api/v1/entities/customer", permission: rbacHandler([Permission.CUSTOMER_CREATE_SINGLE]), middlewares: [validateBody(CustomerCreateBodySchema)], handler: create, }, ]); return router; }

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
import express from "express"; import { mountModules } from "./modules/index.js"; import { errorHandler } from "./http/error-handler.js"; import { openApiRouter } from "./openapi/router.js"; const app = express(); app.use(express.json()); // Public — mounted before the guard. app.get("/api/v1/health", async (_req, res) => { /* ... */ }); app.use(openApiRouter()); // Protected — every route under /api/v1/* declares its permission. mountModules(app); // Centralized error handler — converts ApiError to RFC 7807. app.use(errorHandler); app.listen(3001, () => console.log("API listening on http://localhost:3001"));

OpenAPI

The backend generates its OpenAPI spec from code:

  • src/openapi/openapi.ts — defines the system API spec
  • src/openapi/router.ts — mounts the OpenAPI JSON endpoint
  • src/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:

  1. Audit trail — persistent {table}_audit rows written by dal-pg's AuditPort on 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.
  2. 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.
  3. 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.
Last modified on October 28, 2026
OverviewAPI Conventions
On this page
  • Source layout
  • Module structure
  • The Controller–Service contract
    • Legal transport matrix (decided 2026-10-09)
  • HTTP layer
    • Request flow
    • makeProtectedRouter() — secure-first router factory
    • registerRoutes() — declarative route table
    • asyncHandler() — async error propagation
    • validateBody() / validateQuery() — Zod validation
    • ApiError — RFC 7807 error base class
  • A complete module walkthrough
    • 1. Entity (customer_entity.ts)
    • 2. Service (customers.service.ts)
    • 3. Controller (src/controllers/http/customers.router.ts)
    • 4. Mounting (src/index.ts)
  • OpenAPI
  • Service registry
  • Data access, audit, and events
  • Next steps
TypeScript
TypeScript
TypeScript
TypeScript
TypeScript
TypeScript
TypeScript
TypeScript
TypeScript
TypeScript