Skip to content

Backend architecture

The backend (trile repo) is a NestJS 11 application. This page is the engineer’s mental model.

The same image boots into one of three roles, selected by the PROCESS_ROLE env var:

RoleRunsTalks to
apiThe /v1 HTTP server. Enqueues jobs, never executes them inline.Postgres, Redis (enqueue)
schedulerCron triggers: the daily billing tick, reconciliation, and idempotency-key reaper (03:00 Asia/Kathmandu).Redis (enqueue)
workerExecutes jobs: billing charges, webhook delivery, reconciliation.Postgres, Redis (consume)

Why split: a renewal or webhook delivery must never block an HTTP request, and billing must keep running even if the API is redeployed. Queues: billing-tick, webhook-delivery, reconciliation, idempotency-reaper, plus sms / notification-email.

Each src/modules/* folder is a context with its own controller/service/schema and no cross-context business-logic leaks:

wallet · billing · subscriptions · catalog (products/prices) · checkout · customers · events · webhooks · merchant · team · security · auth · payment (eSewa/Khalti/IME) · email · sms · storage · reconciliation

These are the non-negotiables that make a payments system safe — match them in any new code.

  • Moneybigint paisa in Postgres, hydrated to a Money value object with no .toNumber() escape hatch. See Money & paisa.
  • Idempotency — a global interceptor enforces Idempotency-Key on every mutating /v1/* request, stores the response, and replays it for 24h. See Idempotency.
  • Transactions@Transactional() (via nestjs-cls) wraps money-touching work; row locks (SELECT … FOR UPDATE) protect wallet/ledger updates.
  • Append-only audit & events — every money-touching state change writes an audit_log row; domain events go to an immutable event log surfaced at /v1/events.
  • Transactional outbox — webhooks are written to an outbox in the same transaction as the state change, then delivered by the worker with retries — so an event is never lost if delivery is down.
  • Daily reconciliation — recomputes each wallet balance from its ledger and flags drift. See the reconciliation runbook.
  • Response envelope — a global interceptor wraps results in { success, data, meta }; a global exception filter shapes errors. Health probes opt out via @SkipEnvelope().
  • Pino structured JSON logs with a correlation ID (req_…) per request lifecycle; PII redacted at config time.
  • OpenTelemetry (instrumentation.ts, loaded before anything else in main.ts) + Sentry for errors; worker failures call Sentry.captureException.

@nestjs/swagger + nestjs-zod emit the OpenAPI document at runtime from controllers and DTOs. It’s served at /docs-json — consumed by both frontends’ typed clients and by this docs site’s API Reference.