- Published on
Two Orthogonal Axes of Background Work: Why Time-Triggered and Message-Triggered Must Be Kept Distinct
- Authors

- Name
- Jack Qin
"Write a scheduled job" is something nearly every backend developer can do. The hard part was never writing it; it is answering a more fundamental question: what triggers this background work, which process does it run in, and may it directly reach into other modules? These three questions are orthogonal, but once they blur together in your head, the code starts rotting toward "reference whatever you can reach."
This post is not about "how to configure Quartz" or "how to write a MassTransit consumer." It wants to break background work into a few clean accounts: trigger semantics is one account, process ownership is another, and module communication is a third. Look at these three accounts separately, and "Quartz or MassTransit" stops being a matter of taste and becomes a mechanism question with a definite answer. We'll use the modular monolith of an environmental-monitoring platform as the running worked example throughout (.NET 10, Web-Queue-Worker, RabbitMQ, MassTransit, Quartz, PostgreSQL).
Account one: trigger semantics — clock-driven vs message-driven
The most central choice for background work is essentially the question "what makes this work start running?" The answer comes in only two kinds, and these two kinds correspond to two completely different pieces of infrastructure.
One kind is clock/calendar-driven: the time has come, every so often, repeat on a schedule. Its trigger source is a timetable, independent of anything that happens in the system — calibration reminders, scanning the schedule for pending emails, recomputing the heatmap on a timer all fall into this kind. This is precisely the reason Quartz jobs (CalibrationReminderJob, ProcessEmailSchedulesJob, RecalculateHeatmapJob) exist: their abstraction is "trigger = clock."
The other kind is message-driven: a command or an event has arrived and needs to be handled asynchronously and decoupled. The trigger source is not time but "something happened in the system" — sending an email, scraping asset locations, scraping monitoring data are all this kind (SendEmailConsumer, ScrapeAssetLocationsConsumer, ScrapeSensorDataConsumer). This is the domain of MassTransit consumers: their abstraction is "trigger = a message."
The point is not to memorize which classes use Quartz and which use MassTransit, but to see clearly that these two abstractions model two different kinds of causality. Treating them as "two tools that can both run things on a timer" and picking arbitrarily will sooner or later produce awkward code — a consumer hard-simulating a timer, or a timer polling in place of an event subscription. The criterion should return to the trigger semantics themselves: if the trigger source is a clock, it's Quartz; if the trigger source is a message, it's a consumer.
What is even more worth noticing is that the two are often chained: a Quartz job's time arrives, and rather than doing the work itself, it dispatches a command for a consumer to actually execute. ProcessEmailSchedulesJob scans the schedule on time and dispatches a send-email command; SendEmailConsumer actually sends the email. This chaining reveals a clean division of labor: the job answers "when," the consumer answers "how." Stuffing "when" and "how" into the same component is the first sign that background-work code is starting to become hard to maintain — a fat class carrying both a cron expression and retry/decoupling semantics, where the two concerns drag each other down.
Account two: process ownership — publish and consume split across two hosts
The second account asks "which process should this code run in?" In the Web-Queue-Worker pattern, this boundary is drawn hard:
- The API host is publish-only and consumes no messages.
- The Worker host consumes messages and runs Quartz jobs.
This boundary is not fastidiousness; it has concrete consequences. Background processing often drags a pile of heavyweight dependencies — on this platform, the heatmap rendering service IHeatmapRenderer is Worker-exclusive: AddEmailWorkerServices() depends on it already being registered, and that dependency chain only holds inside the Worker.
So a subtle trap appears: accidentally registering a Worker-exclusive service into the API host. The immediate consequence is that the API drags in a pile of native rendering libraries it has no use for at startup; the deeper consequence is that it masks an architectural problem — a piece of code that should only run in the Worker process has quietly seeped into the request-facing API process. The first consequence is a performance tax; the second is a precursor to boundary collapse. So the rule is simple: if it belongs to the Worker, it should appear only in the Worker. The process-ownership account must be settled right at the dependency-injection wiring.
Account three: module communication — talk only through message contracts
The third account asks "by what means may one module make another module do work?" This is the linchpin of whether a modular monolith can hold up over the long run.
The platform's answer is: cross-module, cross-host communication always goes through a shared abstraction and never directly references the other side. The abstraction layer is the set of interfaces ICommandDispatcher, IEventPublisher, plus the infrastructure-layer MassTransitCommandDispatcher implementation. Three constraints derive from this:
- A module does not directly depend on another host. Modules on the API side may not reverse-reference things in the Worker, and vice versa.
- Don't bypass the message abstraction with ad-hoc cross-module references. Where command/event decoupling is called for, don't take the shortcut of directly
new-ing up the other side's service. - Transport details stay in the infrastructure/host layer. Concrete RabbitMQ and MassTransit types should not appear in module code.
The essence of this set of constraints is to force modules to talk only through message contracts. What it really guards against is a gradual erosion — a modular monolith almost never dies of one big refactor; it is slowly hollowed out by countless "just this once, let me reference it directly" moves. Every direct reference that bypasses the abstraction "to be a little faster" chips a small piece off the boundary; chip past a critical point and "modular" is just a name, with what remains being a big ball of mud no one dares touch.
Note the relationship between the third account and the first two: the discipline of module communication is held up precisely by the first account (use commands/events rather than direct calls) and the second account (split publish/consume across processes). The three accounts are not a flat checklist but projections of the same boundary along different dimensions.
Make modules self-describing: how the registration pattern hands the boundary back to the module
For a boundary to be maintainable, it can't be scattered across some big file in the host — that way every added module forces you back into the host to change it, and the host eventually swells into an omniscient god object. This platform's approach is to let each module manage itself, uniformly exposing three registration hooks:
Add{Module}(services, config)— base registration;AddConsumers(x)— register the module's own MassTransit consumers;AddJobs(q, config)— register the module's own Quartz jobs.
All the Worker host has to do is aggregate: the Quartz registration aggregates each module's jobs, the MassTransit registration aggregates each module's consumers, and ConfigureEndpoints(context) wires up the endpoints uniformly. The host need not know anything about what's inside any module.
The value of this pattern is that modules are self-describing: add a module and it brings its own jobs and consumers along; the boundary is declared by the module itself rather than centrally maintained by the host. This echoes the three accounts above exactly — the discipline of trigger semantics, process ownership, and module communication degrades into a mush at the host layer unless every module can declare its own share independently and locally.
Where the tests should be nailed
Since the hard parts of background work are trigger semantics and dispatch side effects, the tests should be nailed directly to those two spots, rather than taking a big detour through end-to-end:
- Test scheduling or repeat logic directly at the job/consumer layer — don't run a whole end-to-end chain just to verify a cron expression.
- Verify the dispatched side effects and state updates — when the job's time arrives, did it really dispatch that command? After the consumer finishes, did the state land in the database? (See
ProcessEmailSchedulesJobTests,SendHeatmapReportConsumerTests.) - Cover timezone-sensitive behavior, provided the module genuinely depends on timezone. Scheduling-type jobs (weekly reports, daily reports) almost always involve timezone, and that logic is the most prone to breaking on DST transitions and UTC/local mixing — it's fragile, so it must be tested directly.
The trade-off here is clear: testing "did the job dispatch the right command at the right time" has a far higher signal-to-noise ratio than testing "did the whole chain ultimately send an email." The latter gets polluted by a pile of orthogonal failure sources; the former hits the real failure axis of background work directly.
The transferable layer
Set aside the specific Quartz and MassTransit APIs, and the real transferable insight from this case is twofold:
First, "background work" is not an atomic concept but several orthogonal accounts. Trigger semantics (clock vs message), process ownership (publish vs consume), communication means (contract vs direct reference) — ask them separately and selection has a definite answer; mash them together and you'll write fat components and boundary-crossing references. Facing any background work, split the accounts first, then act.
Second, module boundaries are eroded by defaults and shortcuts, not torn down by refactors. A Worker-exclusive service mistakenly registered into the API, a "just this once" direct reference, a newly invented scheduling style (when Quartz is already the repository standard) — each looks insignificant on its own, and accumulates into a big ball of mud. The only way to hold the boundary is to write these constraints as executable contracts (self-describing module registration, tests nailed to the dispatch layer), making "bypassing it" mechanically harder rather than merely written into a doc and left to good behavior.