Skip to content

Scaling out

Going from one replica to many is mostly a driver swap. The framework's default in-memory drivers exist to keep dev / test friction low; none of them survive a second replica. This page lists every contract that needs attention before you scale past one and what to flip.

The Redis swap-list

A single Redis target (host or cluster) typically backs every distributed driver. Wire @modularityjs/redis once with RedisModule.forRoot({ host, port, password }); every *-redis driver depends on the RedisService it exposes.

ContractDev driverProduction driverNotes
CacheServicecache-memorycache-redisTTL set per set(); defaults via defaultTtlMs.
LockServicelock-memorylock-redisSET NX PX + Lua-checked release. Auto-expires after ttlMs; set TTL ≥ longest critical section.
SessionServicesession-memorysession-redisAvoids the need for sticky sessions on the load balancer.
EventBusevents-memoryevents-redisCross-process delivery; default local: false. Required for @OnEvent({ once: true }) to work cluster-wide.
QueueServicequeue-memoryqueue-redisRequired as soon as you have more than one consumer process.
RateLimiterServicerate-limit-memoryrate-limit-redisAtomic INCRBY + PEXPIRE. Memory driver would otherwise count each replica separately.
Storagestorage-memory / storage-localstorage-s3Local disk is per-pod; multi-replica needs a shared object store.

Add @modularityjs/redis-health so the health endpoint includes a Redis PING indicator.

typescript
modules: [
  ModularityModule,
  RedisModule.forRoot({
    host: process.env.REDIS_HOST!,
    port: 6379,
    password: process.env.REDIS_PASSWORD,
    keyPrefix: 'orders-api:',
  }),

  CacheModule, CacheRedisModule.forRoot({ keyNamespace: 'cache:' }),
  LockModule,  LockRedisModule.forRoot({ keyNamespace: 'lock:' }),
  SessionModule, SessionRedisModule.forRoot({ keyNamespace: 'session:' }),
  EventsModule.forRoot({ mode: 'parallel' }), EventsRedisModule.forRoot({ /* ... */ }),
  QueueModule, QueueRedisModule.forRoot({ /* ... */ }),
  RateLimitModule, RateLimitRedisModule.forRoot({ /* ... */ }),

  HealthModule, RedisHealthModule,

  StorageModule, StorageS3Module.forRoot({
    bucket: process.env.S3_BUCKET!,
    region: process.env.AWS_REGION!,
  }),
],

Database migrations

migrationsRun: true on DatabaseModule.forRoot() is single-node only — two pods racing on startup will deadlock or double-apply. Flip it off and run migrations as a separate deploy step:

yaml
- name: Run database migrations
  run: node dist/cli.js database:migration:run

See Build & Deploy → Database migrations.

The current DatabaseTypeormConfig doesn't surface TypeORM's replication field. If you need read/write splitting, extend the config or bind the DataSource yourself in an app-tier module — but be aware that's framework-tier territory (afterLoad + manual container.bind()), reserved for framework code in the supported pattern.

Outbox dispatcher

The outbox tick should run on every replica with the scheduler's distributed lock doing the de-duplication. outbox-scheduler registers itself as a ScheduledJob, so as long as @modularityjs/lock-redis is wired and LockService is multi-node aware, only one replica's tick wins per cycle.

If you'd rather centralize the outbox in a dedicated worker process (e.g. to size it independently), put OutboxSchedulerModule only in the worker entry point. The DB store still ships events from any process (web pods enqueue, the worker dispatches).

Events: local vs once

With events-memory, local: true and local: false behave identically — there's no cross-process bus. The moment events-redis is loaded, the defaults change:

  • Default (local: false) — event runs on every subscribed replica.
  • local: true — event runs only on the replica that called dispatch().
  • once: true — event runs exactly once across the cluster (Redis tracks the event id).

Code written against the memory driver tends to assume "the handler runs in my process" — that assumption breaks when you scale, and every handler runs N times unless you mark it local. Audit @OnEvent handlers before the first multi-replica deploy.

Sessions

session-memory evicts the world on restart and isn't shared across replicas. Switch to session-redis before running multi-replica, even if you only have one pod right now — sticky session config on the load balancer is a footgun that's easy to forget about during a future migration.

Rate limit

rate-limit-memory counts per-replica; an attacker hitting two pods bypasses the limit. rate-limit-redis uses an atomic Lua script and a single counter per key, so the published limit applies to the cluster — the only correct choice once you have more than one replica.

The HTTP extension applies globally per request via onRequest; the default key extractor reads x-forwarded-for. If your traffic enters via a load balancer that doesn't strip / set this header, override keyExtractor in HttpRateLimitConfig — otherwise every request looks like it comes from the LB and the bucket is shared.

Storage

storage-local writes to the pod's disk. On a Kubernetes Deployment that means it dies with the pod and isn't readable from siblings — every upload that was meant to persist is lost or stuck on one replica. Use storage-s3 (or any object-storage-backed driver) once you scale.

Outbound HTTP / webhooks

@modularityjs/webhook-queue (vs. webhook-direct) buffers outbound webhook deliveries into the queue, so a backlog from a slow downstream doesn't stall request handlers. Pair it with queue-redis for the same multi-replica reasons.

The audit

Before the first scaled deploy:

  1. Every memory driver swapped. Grep your modules list for Memory — there should be zero matches outside dev/test wiring.
  2. migrationsRun: false in production config. Confirm the migration step runs in the deploy pipeline.
  3. @OnEvent handlers reviewed for cross-replica behavior. Mark them local/once as appropriate.
  4. Sticky-session config removed from the load balancer (once session-redis is in).
  5. Health probe includes Redis. RedisHealthModule is wired and /health/ready reports it.
  6. Storage points at a shared object store. No storage-local in production.
  7. Boot smoke gate runs against a real Redis in CI. See Build & Deploy → Boot as a smoke gate.

Next Steps