A NestJS application we've maintained for a couple of years finally hit the point where different parts of it needed to scale and deploy independently. The order-processing module was seeing traffic spikes that had nothing to do with the rest of the application, and deploying a small fix to the reporting module meant redeploying, and re-risking, everything else alongside it. NestJS's module structure made the eventual split into services considerably cleaner than we expected going in.
Why the module boundaries mattered
Existing module boundaries mapped almost directly onto service boundaries, which validated the upfront investment in disciplined module design from the original build years earlier. Each NestJS module already had a clear public interface, a defined set of exported providers, and minimal direct imports from unrelated modules. That discipline had felt like slight overkill at the time, some team members pushed back on the ceremony during code review more than once, but it meant the actual extraction was mostly a matter of moving files rather than untangling a web of implicit dependencies first.
The modules that had drifted from that discipline, mostly older code written before the pattern was fully established, were exactly the ones that took the longest to split out. One module had a handful of direct database queries reaching into tables that conceptually belonged to a different domain, and untangling that required a genuine design decision about which service should own that data going forward rather than just a mechanical move.
Moving to a message queue
Moving from direct in-process calls to a message queue for cross-service communication required rethinking error handling and retries in ways the monolith never had to consider. An in-process function call either succeeds, throws, or hangs; a message published to a queue can be delivered, delivered twice, delivered late, or lost entirely if we're not careful about acknowledgment semantics. We adopted an at-least-once delivery model with idempotent consumers, which meant every message handler needed to be safe to run twice on the same message, a property that sounds simple in principle and took real design work to guarantee for operations like inventory decrements that are not naturally idempotent.
- Existing module boundaries mapped almost directly onto service boundaries, which validated the upfront investment in disciplined module design from the original build.
- Moving from direct in-process calls to a message queue for cross-service communication required rethinking error handling and retries in ways the monolith never had to consider.
- Dead-letter queues for messages that fail processing repeatedly turned out to be essential rather than optional; without one, a single malformed message could block an entire queue.
We introduced a dead-letter queue for messages that fail processing after a fixed number of retries, which turned out to be essential rather than a nice-to-have. Early in the rollout, a single malformed message, the result of a schema mismatch between the publishing and consuming service during a deploy, sat retrying in the main queue and blocked every message behind it until someone noticed and manually intervened. After that incident, we made dead-letter routing mandatory for every consumer before it could go to production.
Observability across service boundaries
Debugging a request that spans multiple services over a message queue is genuinely harder than debugging a single monolithic process, since a stack trace no longer tells the whole story. We adopted correlation IDs threaded through every message and log line, generated at the point a request first enters the system and propagated through every downstream hop, which let us reconstruct a request's full path across services when something goes wrong. Without that, the early weeks of running services independently involved a lot of manually cross-referencing timestamps across separate log streams, which is not a sustainable debugging practice at any real scale.
What we'd do differently
NestJS's structure doesn't prevent you from needing to split a monolith eventually, but it makes the split far less painful than an unstructured Express application would when that day arrives. If we were starting the original build again, we'd enforce the module boundary discipline more strictly from day one, since the modules that violated it consistently were the ones that cost us the most time during extraction. We'd also introduce correlation IDs and the dead-letter queue pattern before the first service split rather than after the first production incident that needed them, since both are cheap to add early and expensive to retrofit under time pressure. The full migration took about six weeks for the first two services, and we expect subsequent splits to go faster now that the message queue infrastructure and observability tooling are already in place.
Choosing which module to split first
We didn't split every module at once, and picking which service to extract first mattered more than we initially appreciated. We chose the order-processing module specifically because its traffic pattern was the most independent of the rest of the application, which meant the split would deliver a real, measurable benefit, independent scaling, quickly rather than being a purely architectural exercise with no immediate payoff to point to. That early win made it considerably easier to get buy-in for splitting the next two modules, since the team could point at a concrete before-and-after rather than asking people to trust an abstract architectural argument.
The module we extracted second, by contrast, had weaker traffic independence but stronger deployment independence: the reporting module changed frequently as the analytics team iterated, and every one of those changes had been forcing a full redeploy of the monolith, with all the regression risk that implies for completely unrelated code paths. Splitting it out let the analytics team ship on their own schedule without needing sign-off from teams whose code happened to live in the same deployable artifact for historical reasons rather than any technical necessity.
Testing across service boundaries
Testing changed shape considerably once cross-service communication went through a message queue instead of a direct function call. Unit tests for individual message handlers were straightforward, mock the queue client, assert on the handler's behavior given a message payload, but we needed a new category of test entirely to catch integration issues: a local, ephemeral queue instance spun up in CI that lets us test the full publish-and-consume path across service boundaries without deploying to a shared staging environment for every pull request. Building that test harness took about a week on its own, longer than we expected, mostly because getting the ephemeral queue to reliably start and tear down within a CI job's time budget required more tuning than a single afternoon.
Versioning message schemas across services
Once publishing and consuming services deployed independently, a schema mismatch between them became a real risk in a way it never was inside a monolith, where the compiler would have caught a type mismatch at build time. We adopted a versioned message envelope, every message carries an explicit schema version field, and a consumer explicitly declares which versions it can handle, falling back to a dead-letter route for anything outside that range rather than attempting to process a message shaped differently than expected. That discipline added a small amount of ceremony to every message type we introduced, but it directly prevented a repeat of the schema-mismatch incident that first taught us dead-letter queues were mandatory, this time catching the mismatch immediately rather than after a queue was already blocked.
Load testing the queue before trusting it
Before routing real production traffic through the new message queue, we ran a dedicated load test simulating the order-processing module's actual peak traffic pattern, including the bursty spikes that had motivated the split in the first place. That testing surfaced a consumer concurrency setting that was too conservative by default, processing messages one at a time when the queue's actual throughput needs meant several consumers running in parallel, which would have created a growing backlog during exactly the traffic spikes we were trying to handle better. Tuning consumer concurrency before launch, rather than discovering the bottleneck during a real traffic spike, turned what could have been a rocky first week into a non-event.
The rollback plan we insisted on having
Splitting a monolith into services is a lot easier to do than to undo, so before the first cutover we wrote out an explicit rollback plan and tested it on staging rather than assuming we wouldn't need one. The plan kept the monolith's original order-processing code path dormant but deployable for two full weeks after the split, behind a feature flag that could redirect traffic back to the in-process implementation within minutes if the new service showed problems serious enough to warrant it. We never needed to flip that flag, but having tested the rollback path ahead of time meant the team went into the cutover with meaningfully less anxiety than an irreversible one-way migration would have carried.
What surprised us most
The biggest surprise wasn't technical at all: splitting the monolith changed on-call ownership in ways we hadn't fully planned for ahead of time. When a single monolith goes down, on-call is unambiguous, whoever's on the rotation handles it. Once services are independent, an incident that spans a queue between two services can land in an ambiguous space where each team initially assumes the problem lives in the other service. We ended up writing a short runbook specifically for cross-service incidents, including a default first responder for the queue infrastructure itself, which resolved most of the early confusion within the first month of running services independently.