Architecture Overview
Request lifecycle
src/app.ts builds a single Restana server, runs global middleware (compression, CORS, body parsing, a catch-all error boundary), then calls one registration function per module — e.g. paymentsRoutes(this.appServer, RouteVersion['v1.payments']). Each module owns a *.routes.ts file that wires its own path/method/middleware-chain/controller combinations directly onto the shared server instance:
appServer.post(
`${prefix}/public/generate-bank-details`,
optionalAuthMiddleware,
validate(generateBankDetailsRules),
paymentsController.generatePublicBankDetails,
);
There's no decorator-based routing framework (no @Controller/@Get) — routes are plain function composition. Path prefixes come from a single enum, src/shared/enum/route.config.enum.ts (RouteVersion), so a module's mount point is defined in exactly one place.
Controllers are resolved once via TSyringe's container (container.resolve(PaymentsController)) and reused across requests; the controller methods themselves are typically = async (req, res) => {...} class fields (bound automatically, safe to pass as bare route handlers).
Request validation — two lanes in active use
- Zod (
src/shared/middlewares/zod.validator.middleware.ts,validateZod(schema)): a Zod object schema is.safeParse()'d againstreq.body; on successreq.bodyis replaced with the parsed/coerced value. This is the newer pattern and the one new modules should default to. - validatorjs (
src/shared/middlewares/validator.middleware.ts,validate(rules)): older string-rule objects like{ amount: 'required|numeric|min:1' }. Still used by several high-traffic modules (payments, events among them) — not yet migrated.
A few modules (e.g. guests) have no route-layer validation at all; request shape is only enforced implicitly by what the controller/service destructure off req.body.
Response envelope
Nearly every endpoint responds through one of two helpers in src/shared/utils/response.util.ts:
successfulResponse({ response, data, message, checks }); // -> { status: true, message, data, checks }
errorResponse({ response, message, data, errors, checks, errorCode, statusCode }); // -> { status: false, message, data, errors, checks }
This consistent shape is what lets the generated OpenAPI spec reuse a single success/error envelope component per endpoint instead of modeling responses one-off — see API Reference.
Dependency injection
TSyringe (@injectable() + container.resolve(...)) wires services into controllers and services into each other. There's no request-scoped container — everything resolved via container.resolve is effectively a singleton for the process lifetime unless the class manages its own per-call state.
Async / event-driven work
Background and cross-service work goes through:
- Google Cloud Tasks — for retryable, deferred HTTP-triggered jobs (see
cloudTaskMiddleware; payments cleanup/reconciliation cron endpoints are a good example). - Google Pub/Sub — for event-driven fan-out between services.
- RabbitMQ — for other async queueing.
OpenAPI generation
The API Reference is generated, not hand-written. src/shared/openapi/ holds the shared setup (zod-extend.ts enables .openapi() on Zod schemas, components.ts defines the shared response envelopes, registry.ts collects path definitions, build-spec.ts assembles the final document). Two patterns feed it:
- Zod-native modules — the DTO schema that already drives
validateZod()is annotated in place with.openapi({...}). One schema, no duplication. - Legacy modules (validatorjs or unvalidated) — a sibling
*.openapi.tsfile per module defines doc-only Zod schemas that describe the contract without being wired into the request pipeline, so documenting a module never risks changing its runtime behavior.
See docs-site in the repo root — this site's own source — for the pattern to follow when adding a new module's coverage.