Skip to content

ADR-0011 — NestJS monorepo with 5 apps + 11 libs (over polyrepo)

Status: Accepted (historical; pre-dates this ADR. Codified here so the rationale survives.) Date: Codified 2026-04-25 Author(s): Platform engineering

Context

ebit-api/ is a NestJS monorepo declaring 5 applications sharing 11 libraries (nest-cli.json):

App Port Stack Role
api 4000 NestJS REST + Swagger Public + admin REST surface
rt 4001 NestJS websockets (socket.io) Real-time gateway, namespace /events
bj 4002 NestJS Blackjack game server (currently orphaned per project_ebit_bj_orphan)
bo 4003 NestJS + Swagger Backoffice
speed-roulette 4004 NestJS Speed-roulette game server (in-memory state machine)

Libraries (libs/): _prisma, accounting, auth, gateway, games, integrations, is-localhost, modules, shared, ws-throttler, _prisma's sibling.

Each app has its own apps/<name>/ root, main.ts, tsconfig.app.json, build target, runtime container. They are deployed independently — port and lifecycle differ per app.

The decision to ship as a NestJS monorepo (vs separate repos per app, vs one giant app with feature modules) pre-dates this portal. Three rationales co-exist in commit history; this ADR codifies the canonical one.

Decision

ebit-api/ is a NestJS monorepo containing 5 independently-buildable apps and 11 shared libs, with a single Prisma schema split across three files (ADR-0006) and a single package.json / tsconfig.json / nest-cli.json at the repo root.

Per-app deploy is supported via Nest's per-project build target (nest build <app>). Cross-app calls go via the @ExternalControllerClient decorator over Redis pub/sub, which has the trace-propagation limitation in ADR-0005.

Sibling repos (ebit-fe/, ebit-admin-fe/) stay separate — they have different frameworks (ebit-fe is Next.js 14, ebit-admin-fe is Vite + React 19), different package manager (pnpm vs ebit-api/'s npm), and different deploy cadences. The "monorepo" decision applies only to the backend.

Considered alternatives

A. Per-app polyrepo (one git repo per app)

Each of api, rt, bj, bo, speed-roulette lives in its own repo with its own package.json. Rejected for these reasons:

  • Shared types drift. The Bet DTO is consumed by api (writes), rt (broadcasts), and bo (admin reads). In a polyrepo each repo pins its own copy of the types lib; semver mismatches are inevitable. Atomic refactors of shared shapes become multi-repo PRs. The monorepo's libs/games/, libs/accounting/, libs/_prisma/ solve this.
  • Prisma client duplication. Five apps each generating their own Prisma client from a shared schema → five separate node_modules/@prisma/client copies, five drift risks, five image-size hits. The monorepo generates once.
  • OTel SDK bootstrapping at the same version across apps is a hard constraint (see project_otel_integration_gotchas); polyrepo dependency drift breaks tracing in subtle ways.
  • CI cost. Five repos × five CI configurations × five build pipelines. Monorepo amortises this.
  • PR review overhead. A bug fix that touches bet.dto.ts across api, rt, bo is one PR in monorepo, three in polyrepo.

B. Single app with feature modules (no apps/ split)

Collapse all five into one Nest app with feature modules (bets/, casino/, rt-gateway/, bo/, speed-roulette/). Rejected because:

  • Per-app deploy lost. rt is throughput-bound (websocket fan-out); api is latency-bound (sync REST). Scaling them independently is essential — one process means one scaling dimension.
  • Per-app failure isolation lost. A speed-roulette state-machine bug shouldn't take down api's sign-in flow.
  • Per-app resource footprint differs. speed-roulette is single-tenant by design (concurrency=1, in-memory state); api is horizontally-scalable behind a load balancer. Same process can't honour both.
  • Build time inflates. A single Nest app with 5 services' worth of code means a 5× longer hot-reload during dev.

C. Mixed: monorepo at the source level, polyrepo at the deploy level (Nx-style)

Use Nx (or similar) to manage the source as one repo, deploy artefacts split per app. Considered; current setup achieves the same outcome via NestJS's native monorepo mode without adopting Nx. The Nx route would add tooling weight (Nx generators, Nx executors, Nx graph) without fundamental gain at our scale.

D. Lerna / pnpm workspaces

Same shape as A but with workspace tooling unifying the install. Rejected because the monorepo also wants build-time TypeScript path aliases (@api/*, @rt/*, @app/shared, @app/auth, etc. — see tsconfig.json paths). NestJS's native monorepo mode wires this without a workspace tool layered on top.

Consequences

Build & deploy

  • Per-app nest build <name> produces per-app dist/. Containers are per-app (Dockerfile inputs the app name).
  • Single package.json at the repo root means dependency upgrades are atomic across apps. The risk: a dep that one app needs but another doesn't ships in every container. Mitigation: dev-deps stay dev-deps; production bundles tree-shake.
  • Single tsconfig.json means one set of compiler options. Per-app override via tsconfig.app.json is possible but kept minimal.

Cross-app calls

  • No HTTP between apps. Cross-app calls use @ExternalControllerClient over Redis pub/sub via libs/gateway/src/ms-controller/. This is the single biggest cost of the monorepo decision: trace propagation breaks across this transport (ADR-0005). The three-hop speed-roulette flow surfaces as three uncorrelated traces in Jaeger. Documented gotcha; not a bug.
  • Internal type sharing is automatic — the callee's controller method signature is the source of truth for the caller's RPC stub.

Refactoring

  • Atomic refactors. Renaming Bet.amount to Bet.totalAmount is one PR touching all five apps in lockstep. Polyrepo equivalent would be 5 PRs with sequencing.
  • Single Prisma schema (split across 3 files; ADR-0006) means migrations are repo-level events.

Per-app concerns

  • api (4000) — public + admin REST, horizontally-scalable.
  • rt (4001) — websockets only; socket.io reads JWT from cookie not Authorization header (project_otel_integration_gotchas). Throughput-bound.
  • bj (4002) — currently orphaned (project_ebit_bj_orphan). The blackjack feature surfaces through api + slot-provider integration; bj's app process is not consumed in production. Either delete or revive.
  • bo (4003) — backoffice; admin-only auth; smaller surface (~58 ops).
  • speed-roulette (4004) — in-memory state machine, concurrency=1 invariant, single-tenant per-process. Cannot horizontally scale; vertical-scale only.

git history

  • History pollution. Refactors and dep bumps land in one tree; commit graph is busy. Mitigation: git log filters by path (git log -- apps/rt/) for per-app history.

Monorepo escape hatch

  • If rt becomes the bottleneck and needs a different runtime (Bun, Deno, separate dep tree), it can be split out. The cost of split is contained because libs/ are clean (no Nest-specific assumptions in shared types). The split is a one-time exercise; the monorepo is not a permanent commitment.

Revisit triggers

Reopen this decision if:

  1. rt is bottlenecked by the monorepo dep tree (e.g. it wants Bun, the rest stays on Node).
  2. The team grows past ~5 backend engineers and per-app ownership starts to fragment.
  3. A second backend stack (Python, Go) needs to coexist; the monorepo cannot host non-TypeScript code.

Until one of those happens, the monorepo stays.

References

  • ebit-api/nest-cli.json — apps + libs declarations.
  • ebit-api/tsconfig.json — path aliases.
  • docs/architecture.md — per-app responsibilities + deploy topology.
  • CLAUDE.md — workspace layout summary.
  • libs/gateway/src/ms-controller/@ExternalControllerClient implementation.
  • Memory: project_ebit_bj_orphan.md — current state of the bj app.
  • Memory: project_otel_integration_gotchas.md — socket.io JWT cookie gotcha + cross-app trace gap.
  • Sibling ADRs: 0005 (transport gap), 0006 (Prisma split), 0009 (forensic store).