Skip to main content

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 against req.body; on success req.body is 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:

  1. Zod-native modules — the DTO schema that already drives validateZod() is annotated in place with .openapi({...}). One schema, no duplication.
  2. Legacy modules (validatorjs or unvalidated) — a sibling *.openapi.ts file 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.