Most teams get BullMQ working in NestJS within an afternoon. Getting it architected well is a different problem entirely — and it's the one that actually matters once the codebase has more than two developers and more than one queue.
The failure mode I keep seeing isn't "BullMQ doesn't work." It's that queue logic slowly becomes the most tangled part of the codebase — job-adding calls scattered across services, queue names duplicated as string literals, and a "shared" queue module that ends up silently coupled to every domain it was supposed to stay agnostic of. None of this shows up in a demo. All of it shows up eighteen months in, when the fifth engineer joins the team and asks "wait, why does the queue module import the User service?"
This article isn't a how-to. It's the reasoning behind a specific architecture — one built around a clear separation between infrastructure and domain logic, and around events as the connective tissue between them. If you're the person responsible for how a system holds together as it grows, this is the layer worth getting right early.
Why This Matters at Scale
Background job processing is one of the first places architectural discipline either pays off or quietly erodes. A queue is, structurally, a integration point — it's where a domain hands off responsibility to something that runs later, on a different clock, sometimes on a different process entirely. That handoff point is exactly where coupling likes to sneak in, because it's tempting to treat "the queue" as one big shared utility that everyone reaches into.
The problem compounds for three reasons specific to scale:
Team boundaries stop matching code boundaries. When one team owns Orders and another owns Offers, and both are reaching into a shared queue module that also happens to know about job names, retry policies, and business semantics for both domains — every change becomes a cross-team negotiation. The queue infrastructure becomes a bottleneck that has nothing to do with actual throughput.
Failure isolation degrades. If job logic and job options are scattered inconsistently across the codebase, a bug in one domain's job handling starts affecting reliability assumptions for domains that have nothing to do with it. A well-bounded system should let one queue misbehave — back up, fail, retry aggressively — without that blast radius reaching unrelated domains.
Extraction becomes expensive. Systems that start as a modular monolith often need to peel a domain out into its own service later. If a domain's queue logic (producer, processor, job names) is entangled with shared infrastructure, that extraction turns into a multi-week untangling exercise instead of a clean module lift.
The architecture below is built specifically to avoid these three failure modes — not by adding more abstraction for its own sake, but by being deliberate about exactly two things: what counts as infrastructure, and what counts as domain logic, and never letting the two blur.
The Core Separation: Infrastructure vs. Domain
Everything in this architecture flows from one distinction.
Infrastructure is the connection to Redis, the queue registration mechanism, and nothing else. It has no opinion about what a "user" or an "order" is. It doesn't know job names exist. Its entire job is to make BullMQ available to the rest of the application without every module having to independently configure a Redis connection.
Domain logic is everything else: what queues exist, what jobs run on them, what triggers them, and what happens when they execute. Each domain module — User, Order, Offers, whatever bounded contexts the system has — owns this completely, end to end.
The test for whether something belongs in infrastructure or domain: does this code need to know what the business does? Redis connection pooling doesn't. Deciding to send a password reset email does. The moment infrastructure code contains a reference to "registration" or "invoice," it has quietly become domain code wearing an infrastructure costume — and that's the seam that causes pain later, because now two unrelated domains are compile-time coupled through a module that was supposed to be neutral ground.
This is not a novel idea in system design generally — it's the same instinct behind keeping a message bus dumb and letting producers/consumers own their own schemas, or keeping an API gateway free of business logic. BullMQ inside NestJS just makes it easy to violate, because the framework doesn't force the separation on you. You have to choose it.
Events as the Decoupling Mechanism
The second architectural decision is using domain events — via an in-process event emitter — as the trigger between a domain action and a queue job, rather than having a service call queue.add() directly.
This matters most in one specific scenario: when more than one concern needs to react to the same thing happening. Consider a user registration. In a system of any real size, registration isn't just "send a welcome email." It's potentially: send a welcome email, calculate an eligibility-based offer, notify an internal analytics pipeline, and trigger a fraud-check job — four entirely separate concerns, owned by four separate teams or modules, all reacting to one fact: a user registered.
If the User service directly called into each of those queues, it would need to know all four exist. Every new consumer of "user registered" would require modifying User's code — a violation of the basic idea that a module shouldn't need to change because something downstream of it grew a new requirement. Emitting a single domain event and letting each interested module listen independently inverts that dependency. User module knows nothing about Offers, Analytics, or Fraud. Each of those owns its own listener, decides independently what to do, and can be added or removed without anyone touching User's code at all.
This is worth being honest about: it's not free decoupling. Introducing an event emitter as an indirection layer is only worth it when there's a real "one thing happened, several things should react" shape to the problem. For a single, one-to-one relationship — one action always triggers exactly one job, forever — a service calling a queue directly is simpler and the event layer is just ceremony. The architectural judgment call is recognizing which shape you're actually in, and not defaulting to indirection out of habit.
What Owns What
Once the infra/domain split and the event-driven trigger are in place, the remaining question is placement — and this is where a lot of teams get sloppy, usually by defaulting to "put anything that feels shared into the shared module."
The rule that holds up: ownership follows the domain, not the mechanism. A queue producer — the class responsible for actually calling .add() with the right job options — is domain logic, even though it talks to infrastructure. It belongs inside the domain module that owns that queue, not inside the shared infrastructure module. Same for the processor that does the work when the job runs, same for the listener that reacts to the event, same for the constants defining that domain's queue name and job names.
The shared infrastructure module's job is to answer one question only: how do we connect to Redis, and how do modules register a queue. It should be importable by every domain module and should never need to import anything back from them. If you ever find the infrastructure module needing to know about a specific domain's job types, that's the signal something has been placed in the wrong layer — and it's far cheaper to catch that in code review than in a production incident six months later.
This also answers a subtler question that comes up once a second module needs to react to the same event a first module owns: does the listener live with the event's owner, or with the reactor? It lives with the reactor. The module reacting to "user registered" — whether that's Offers, Analytics, or anything else — owns its own listener, its own producer, its own processor, entirely independent of the module that emitted the event. The only thing that crosses the module boundary is the event's data shape itself, which is a narrow, intentional, and stable contract — not unlike a shared interface or DTO. That's a deliberate, acceptable coupling, distinct from coupling to another module's internal behavior.
Where This Breaks Down (And Where It's Overkill)
No architecture is free of tradeoffs, and it's worth naming where this one has genuine costs.
The event-emitter indirection adds a layer of indirection that makes tracing a request's full path harder — "what happens when a user registers" now requires knowing every listener that exists across the codebase, since there's no single place that lists them. This is a real cost for onboarding and debugging, and it's the direct price of the decoupling benefit described above. Tooling (consistent logging per listener, an event catalog, or simply strong naming conventions) mitigates this but doesn't eliminate it.
For small systems — a handful of job types, one team, low expected growth — this entire structure is arguably more ceremony than the problem warrants. A single service directly calling queue.add() is fewer files, faster to read, and perfectly correct. The architecture earns its cost specifically at the point where multiple teams, multiple domains, or multiple independent reactions to the same event enter the picture. Applying it prematurely is its own form of overengineering — worth naming explicitly, since "always use the fully decoupled version" is exactly the kind of blanket rule that produces unnecessary complexity in a genuinely small system.
The Resulting Shape
Put together, the architecture looks like this:
![]()
What the diagram should make visible is the asymmetry: the infrastructure module sits at the bottom of the dependency graph, imported by everything, importing nothing domain-specific. Each domain module is a closed loop — controller through processor — with the only cross-boundary contact being event classes passed between modules that have a genuine reason to react to each other.
That asymmetry is the actual architecture. Everything else — which package to install, how to configure the Redis connection, the exact shape of a processor class — is implementation detail that changes with library versions. The separation of infrastructure from domain, and the use of events specifically where fan-out is real, is the part that should still be true five years and several BullMQ major versions from now.