Quartz ↔ unused dependency decoupling¶
Scoping note and options analysis for PR #2591.
Why this matters¶
SyRF.Quartz is structurally a thin MassTransit Job-Service host: its only job is to run the scheduler, persist job sagas in SQL Server, and dispatch IJobConsumer<T> messages across the bus to whichever service owns the handler (today: the PM service). The actual domain work never runs in the Quartz process.
Despite this, the Quartz service transitively pulls in and registers:
SyRF.Mongo.Common— full Mongo driver, UnitOfWork, repositories, change-stream infrastructureSyRF.AppServices— S3 file service + related infrastructureS3Settings/IS3PostSigner— with a placeholder registration inProgram.csso DI validation doesn't failDatabaseMetadataWriter→ writes a heartbeat doc to thesyrf_metadataMongo collection on startup/shutdown
The symptoms this causes:
- SonarCloud scans Mongo/S3 library code under the
syrf-quartzproject. Coverage-exclusion workarounds were added on PR #2461 (now reverted) to silence false-negatives forMongoUnitOfWorkBase,MongoRepositoryBase,MongoExtensions,MongoUpdateBuilder— shared-lib classes whose real coverage comes from the API/PM test suites, but which read as "uncovered" under the Quartz project's sonar scope. - DI graph fragility. Program.cs:82-99 has multiple explicit registrations with comments explaining they exist only to stop Lamar's
AssertConfigurationIsValid()from throwing because of auto-scan side-effects. - Dockerfile build context bloat. Quartz's Docker build needs the whole monorepo because MSBuild has to resolve ProjectReferences it doesn't functionally need.
- Conceptual smell. Reviewers reading the Quartz service have to mentally filter out Mongo/S3 "noise" to understand what it actually does.
Empirical findings (grounded in the current code)¶
Read against main @ ad8ed5d2b (2026-04-23).
Quartz's direct ProjectReference (just one)¶
src/services/quartz/SyRF.Quartz/SyRF.Quartz.csproj:34:
<ProjectReference Include="..\..\..\libs\webhostconfig\SyRF.WebHostConfig.Common\SyRF.WebHostConfig.Common.csproj" />
Transitive chain¶
SyRF.Quartz
└─ SyRF.WebHostConfig.Common
├─ SyRF.AppServices ← S3 file service, IS3PostSigner impl
├─ SyRF.Mongo.Common ← Mongo driver, repos, UoW, DatabaseMetadataWriter
└─ SyRF.SharedKernel ← S3Settings, IS3PostSigner interface, SyrfRegistry
Quartz's using declarations confirm the surface it actually consumes:
using |
What it's used for |
|---|---|
SyRF.WebHostConfig.Common.Extensions |
CreateSyrfWebApplicationBuilder, AddSyrfInstrumentationAndHealthChecks, AddOpenTelemetryConfig, ConfigureSyrfMassTransit |
SyRF.WebHostConfig.Common.Infrastructure |
Logger, bootstrap |
SyRF.SharedKernel.Settings |
GitVersion, S3Settings (registered but unused) |
SyRF.SharedKernel.Infrastructure |
SyrfEnvironment, TaskRegistry (IRunAtInit discovery) |
SyRF.SharedKernel.Infrastructure.ConfigModel |
AppSettingsConfig |
SyRF.Mongo.Common |
AddSyrfMongoServices, DatabaseMetadataWriter (for heartbeat only) |
Who registers the heartbeat¶
src/libs/mongo/SyRF.Mongo.Common/MongoLamarRegistry.cs:21-29:
For<DatabaseMetadataWriter>().Use(ctx =>
{
var collection = mongoContext.GetBsonCollection(DatabaseMetadataWriter.CollectionName);
// ...
return new DatabaseMetadataWriter(collection, gitVersion, appSettings, environment, logger);
});
So every service that registers Mongo inherits heartbeat telemetry to the syrf_metadata Mongo collection — regardless of whether that service's primary persistence is Mongo or SQL.
Who reads syrf_metadata (and what they use it for)¶
This is the decisive finding — it reframes the whole analysis. Grep across the repo, cluster-gitops, and camarades-infrastructure:
| Consumer | What it reads | Purpose |
|---|---|---|
src/charts/preview-infrastructure/templates/database-lifecycle.yaml:262-277 |
targetDb.syrf_metadata.findOne({_id: 'meta'}) |
Mongosh health-check script for the database-lifecycle operator. Prints "Connected services: N, Lifecycle events: M" for the target Mongo DB. |
| PR-comment workflow (per design doc) | syrf_metadata.dataSource.snapshotTimestamp |
Reports snapshot age when a PR preview is provisioned. |
| Snapshot producer / restore job | syrf_metadata.dataSource.* |
DB-provenance bookkeeping during the snapshot → restore pipeline. |
No SyRF service reads syrf_metadata at runtime. Grep of src/services/**/*.cs shows only writers (CreateIndexesAndMapping, DatabaseSeeder, CreateSagaTtlIndex push events; DatabaseMetadataWriter itself writes startup/disconnect). Design-doc Goal 2 ("services read metadata on startup to understand what database they're connected to") was never implemented.
The important property that follows: syrf_metadata is per-Mongo-database self-description, not a cross-service registry. Each document lives in one Mongo DB (syrftest, syrf_staging, syrf_pr_N) and describes that database: what data it holds, who reseeded it, and — as a secondary signal — which services have connected to it. Consumers are per-Mongo-DB tooling (lifecycle operator, snapshot pipeline, PR-comment workflow).
Why Quartz's current heartbeat write is architecturally incorrect¶
Quartz doesn't own a Mongo database. Its primary persistence is SQL Server (saga state). When Quartz's DatabaseMetadataWriter fires today, the write lands in whichever application Mongo DB the Quartz pod happens to be pointed at — in practice, the shared syrftest / syrf_staging / syrf_pr_N that PM and API own.
Consequences:
- Quartz's entry appears under
services.{serviceName}in PM's and API's self-description document, adding noise to the health-check's "Connected services: N" count for a DB that Quartz has no domain relationship with. - None of the existing consumers of
syrf_metadatacare about Quartz. The lifecycle operator is asking "are the Mongo-using services reporting to this DB?"; the snapshot tooling cares about DB provenance; neither question benefits from Quartz appearing. - The only thing Quartz would contribute meaningfully is service liveness — and that's already covered by K8s liveness/readiness probes, Elastic APM, Sentry, and ArgoCD deploy notifications for every service in the platform.
So Quartz's heartbeat write is both (a) landing in the wrong place (a DB Quartz doesn't own) and (b) duplicating a signal that's already emitted via proper channels. Dropping it is a net reduction in confusing data, not a loss of observability.
Why S3Settings gets registered in Quartz¶
// Register S3Settings so the Lamar scanner's IS3PostSigner registration can construct
// S3PostSigner without throwing. Quartz doesn't use S3 directly, but SyrfRegistry's
// WithDefaultConventions() picks up S3PostSigner from SyRF.SharedKernel. Without an
// explicit registration Lamar auto-creates S3Settings via default constructor (all nulls),
// and S3Settings.BaseUri throws UriFormatException during AssertConfigurationIsValid.
The root cause is SyrfRegistry.cs:14-21:
Scan(cfg =>
{
cfg.AssembliesFromApplicationBaseDirectory(ass => ass.FullName.StartsWith("SyRF"));
// ...
cfg.WithDefaultConventions();
cfg.With(new AddProxyConvention());
});
WithDefaultConventions() maps every IFoo → Foo pair it finds in any SyRF-prefixed assembly under the application base directory. Since S3PostSigner : IS3PostSigner lives in SyRF.SharedKernel (always loaded), it always gets registered. Its constructor requires S3Settings — so every host must register S3Settings regardless of whether it uses S3.
Quartz has zero IJob implementations¶
The three IJobConsumer<T> implementations live in SyRF.ProjectManagement.Endpoint.Consumers, register with PM's MassTransit bus in PM Program.cs:206-226, and run in PM's process with PM's Mongo repositories.
Root-cause diagnosis¶
Three distinct design choices combine to produce the problem:
SyRF.WebHostConfig.Commonis a grab-bag bundling all cross-cutting concerns: MassTransit + RabbitMQ, Mongo registration, OTEL, health checks, CORS, impersonation middleware, etc. Any service wanting any of those features inherits all of them.SyrfRegistry(inSyRF.SharedKernel) uses unbounded convention scanning. It crawls every loaded SyRF assembly and auto-registers implementations viaWithDefaultConventions(). There is no mechanism to say "I'm an SQL-only service, skip Mongo/S3 auto-wiring."DatabaseMetadataWriteris hard-wired to Mongo. The heartbeat telemetry concern (a generic cross-cutting need) is implemented only as a Mongo collection writer insideSyRF.Mongo.Common.
Each is fixable in isolation. They compound: even a partial fix leaves one of the other two intact and the coupling persists.
Option space¶
Option A — Split SyRF.WebHostConfig.Common into opt-in mixins¶
Decompose the grab-bag library into focused packages:
SyRF.Hosting.Core— host bootstrap, logging, HTTP pipeline, CORS, health checks, OTELSyRF.Hosting.MassTransit—ConfigureSyrfMassTransit+ RabbitMQ wiringSyRF.Hosting.Mongo—AddSyrfMongoServices, Mongo health checkSyRF.Hosting.FileStorage— S3 client + file servicesSyRF.Hosting.Quartz— (new) scheduler/saga bootstrap, extracted from Quartz's Program.cs
Services opt in to what they need:
| Service | References |
|---|---|
| API | Core + MassTransit + Mongo + FileStorage |
| PM | Core + MassTransit + Mongo + FileStorage |
| Quartz | Core + MassTransit + Quartz |
| S3-notifier | Core + MassTransit (already slim) |
Pros
- Removes the transitive Mongo/S3 coupling at compile time. Quartz stops building those assemblies entirely.
- Sonar's syrf-quartz project genuinely stops seeing src/libs/mongo/** files — no exclusion workarounds needed.
- Each package has a single, documentable purpose (Stable Dependencies Principle, Package Cohesion).
- Easier onboarding: reading Quartz.csproj tells you exactly what cross-cutting concerns it uses.
Cons
- Largest refactor: every service's Program.cs changes, plus new .csproj packages, plus updated Docker build contexts.
- SyrfRegistry still needs a companion fix (Option B) because even inside Core, convention-scanning is greedy.
- Package-level version drift: introducing 5 libs means 5 version bumps on every shared change.
- If packages are published as internal NuGets, release tooling needs changes. (If kept as ProjectReferences, this cost disappears.)
Option B — Scope SyrfRegistry scanning to an explicit assembly list¶
Change SyrfRegistry (or the SyrfRegistry(...) constructor) to accept an opt-in list of SyRF assemblies to scan, instead of unconditionally scanning all SyRF-prefixed assemblies on disk. Services pass their own profile:
registry.IncludeRegistry(new SyrfRegistry(new[] {
typeof(QuartzServiceCollectionExtensions).Assembly, // SyRF.Quartz
typeof(HostBuilderExtensions).Assembly, // SyRF.WebHostConfig.Common
typeof(SyrfEnvironment).Assembly, // SyRF.SharedKernel
}));
Pros
- Minimal surface change — a single class.
- Eliminates the S3Settings placeholder hack.
- No need for new NuGet packages / project restructuring.
- Every service explicitly declares its assembly scope — documentation-as-code.
Cons
- Doesn't actually remove compile-time coupling. SyRF.Mongo.Common is still built when Quartz is built, because the csproj chain still resolves to it. SonarCloud still analyses it under the syrf-quartz project.
- Only solves the runtime DI noise, not the build-time dependency surface.
- Easy to break silently — if a service forgets to include an assembly that a transitive dep needs, DI discovery fails opaquely at runtime.
Option C — Quartz as a standalone MassTransit Job-Service host with no WebHostConfig dependency¶
Rewrite Quartz's Program.cs to reference only:
MassTransit.Quartz+MassTransit.EntityFrameworkCore(direct NuGets — already there)- A new tiny
SyRF.Observabilityproject (see Option D) - Nothing else SyRF-internal
Health checks, OTEL, logging, RabbitMQ connection come from direct MassTransit/OTEL NuGet usage, not from SyRF.WebHostConfig.Common.
Pros - Maximum decoupling. Quartz has zero domain-lib references. - Matches the mental model: "Quartz is scheduling infrastructure, period." - Simpler sonar story: Quartz sonar project sees only Quartz + MassTransit saga code.
Cons
- Duplicates cross-cutting setup (logging, OTEL, health-check conventions) that is currently centralised. Future updates to observability configuration need to be applied in two places.
- Loses the implicit contract that "all SyRF services report heartbeat the same way." If the team values that uniformity, Option A or D is a better fit.
- Largest behavioural risk — any subtle difference between Quartz's custom bootstrap and SyRF.WebHostConfig.Common's shared one becomes a drift point.
Option D — Extract heartbeat/lifecycle telemetry into SyRF.Observability — rejected¶
Status: rejected after the "Who reads
syrf_metadata" analysis above. Kept in the doc for the record.
Original proposal: refactor DatabaseMetadataWriter out of SyRF.Mongo.Common into a new SyRF.Observability project:
- Define an abstract
IServiceHeartbeatSinkcontract (e.g.,WriteStartup(),WriteDisconnect()). - Provide a Mongo implementation (
MongoServiceHeartbeatSink) as a plugin that's wired up when a service registers Mongo. - For SQL-only services (Quartz), provide a SQL implementation — or no-op.
Why this was attractive
The original framing assumed Quartz needed an equivalent heartbeat mechanism after losing the Mongo dep — otherwise Quartz would "lose observability." D was introduced to preserve feature parity by abstracting the sink.
Why this is wrong
syrf_metadatais per-Mongo-DB self-description, not a cross-service liveness registry (see empirical findings).- Quartz doesn't own a Mongo DB, so its entry in
syrf_metadatatoday is already architecturally incorrect — a SQL-backed sink would relocate the same wrong signal to a different store. - Quartz's actual liveness/lifecycle observability is already provided by K8s probes, Elastic APM, Sentry, and ArgoCD deploy notifications. No Quartz consumer is missing data if the Mongo heartbeat goes away.
- Building
SyRF.Observability+ a SQL sink preserves a "feature" with no consumer. Pure YAGNI.
Conclusion: Option A can ship without D. Quartz simply stops writing a heartbeat — nothing downstream reads Quartz's heartbeat, so nothing breaks.
If in future we genuinely want a cross-service lifecycle feed (e.g., "show me every deploy/restart across the platform in one place"), the right tool is log aggregation / APM / ArgoCD release tracking — not a Mongo collection. That need would be better served by a separate design, not by retrofitting syrf_metadata into a role it wasn't built for.
Option E — Stopgap: stop using Mongo from Quartz, keep structure¶
Leave the project structure alone. Just:
- Remove the
registry.AddSyrfMongoServices(...)call from Quartz's Program.cs. - Remove the
DatabaseMetadataWriterinvocation (or replace with a SQL-based heartbeat, or drop heartbeat for Quartz). - Add assembly filter to
SyrfRegistry(Option B) so the S3-Settings placeholder hack can be deleted.
Pros - Smallest patch — fits in a single PR, few files changed. - Immediately removes the user-facing excuse for the sonar-exclusion workarounds (the Mongo classes are still compiled but unused by Quartz's runtime code path, so the "covered elsewhere" argument becomes cleaner). - Low risk — no ProjectReference changes.
Cons
- Compile-time coupling is unchanged. src/libs/mongo/** is still in the Quartz sonar project's scan path.
- Treats a symptom, not the disease.
- Loses Quartz's heartbeat telemetry unless replaced with something equivalent.
Comparison table¶
Revised 2026-04-24 after the "Who reads syrf_metadata" finding. Heartbeat column now reflects that Quartz has no downstream heartbeat consumer, so "losing" it is not a regression.
| Option | Build-time decoupled? | Runtime decoupled? | Effort | Heartbeat impact | Risk |
|---|---|---|---|---|---|
| A. Split WebHostConfig | ✅ | ✅ | High | Quartz heartbeat drops (no consumer — net positive) | Medium |
| B. Scope SyrfRegistry scanning | ❌ | Partial | Low | N/A | Low |
| C. Quartz standalone host | ✅ (fully) | ✅ | High | Quartz heartbeat drops | High (drift) |
| D. Extract Observability | ❌ (on its own) | ❌ (on its own) | Medium | Preserves a signal no one reads | Low — but rejected as YAGNI |
| E. Stopgap removal | ❌ | ✅ | Low | Quartz heartbeat drops | Low |
Recommendation¶
Revised 2026-04-24.
A + B as the target state. D is explicitly rejected (see Option D section above).
Rationale:
- A is the only option that removes the compile-time Mongo coupling, which is the root of the sonar-scanning issue.
- B is additive and independently valuable. It removes the
S3Settingsplaceholder hack today without waiting for the bigger refactor, and it's trivially reversible if A never lands. - D was originally framed as a prerequisite for A ("Quartz needs an alternative heartbeat sink"). The empirical consumer analysis shows no downstream reader of Quartz's heartbeat exists — so Quartz simply stops writing it. No abstraction, no new lib, no SQL sink.
- Option E (minimal "stopgap") is effectively the early part of A: remove
AddSyrfMongoServicesfrom Quartz'sProgram.csand let the.csprojfollow.
Explicitly not recommended: C. The drift risk from duplicating bootstrap outweighs the extra isolation. A's mixins give 90% of the isolation benefit while keeping the shared-convention contract.
Explicitly not recommended: D. Preserves a signal no one reads. YAGNI. If cross-service lifecycle telemetry ever becomes a real requirement, the right tool is log aggregation / APM / ArgoCD release tracking — not a bespoke heartbeat abstraction.
Suggested delivery order¶
- Slice 1 (this PR) — Option B: scope
SyrfRegistryscanning, removeS3Settingsplaceholder registration in Quartz. Pure-positive change, no breakage risk. - Slice 2 — Option E (embedded in path to A): remove
AddSyrfMongoServices+DatabaseMetadataWriterinvocation from Quartz'sProgram.cs. Quartz no longer opens a Mongo connection at runtime. Quartz's observability continues via K8s probes + Elastic APM + Sentry + ArgoCD deploy notifications (unchanged). - Slice 3 — Option A: split
SyRF.WebHostConfig.Commoninto mixins (Hosting.Core,Hosting.MassTransit,Hosting.Mongo,Hosting.FileStorage,Hosting.Quartz). Quartz's.csprojloses theSyRF.WebHostConfig.Commonreference and gainsHosting.Core+Hosting.MassTransit+Hosting.Quartz. Mongo assemblies stop being compiled with Quartz. - Slice 4 — ADR capturing the final shape and the "Quartz has no Mongo" contract.
After slice 2 the sonar-scope situation is already runtime-resolved: Quartz no longer has any runtime dependence on Mongo infrastructure, so future sonar-exclusion requests for src/libs/mongo/** can be declined on principle. Slice 3 is the "right shape" cleanup that makes this obvious at the csproj level too and finally removes SyRF.Mongo.Common from Quartz's build.
Principles applied¶
- Stable Dependencies Principle (Martin): packages should depend in the direction of stability.
SyRF.Hosting.Coreis more stable thanSyRF.Hosting.Mongo, so today's Quartz → WebHostConfig → Mongo chain inverts the right direction. - Interface Segregation Principle: services shouldn't be forced to depend on interfaces they don't use.
SyrfRegistry.WithDefaultConventions()today forces Quartz to care aboutIS3PostSigner. - Single Responsibility at the package level (Clean Architecture, "Package Cohesion" / REP-CCP-CRP trio):
SyRF.WebHostConfig.Commonviolates this by bundling unrelated cross-cutting concerns. - MassTransit's Job Service pattern (per MT's own guidance): the job-scheduler host should be a thin process whose sole responsibility is durable saga orchestration and message dispatch. Consumers live in domain services. This codebase already follows that architectural choice — the fix is to make the dependency graph reflect it.
Open questions (for discussion before implementation)¶
- Is the
syrf_metadataheartbeat Mongo collection actually used downstream? ✅ Answered 2026-04-24. Consumers are per-Mongo-DB tooling only: the preview-infrastructure mongosh health-check script (database-lifecycle.yaml:262-277), the snapshot/restore pipeline, and the PR-comment workflow. No SyRF service reads it at runtime. Quartz's entry in the document has no downstream consumer — dropping it is a net-positive (removes noise from the "Connected services" count in a DB Quartz doesn't own). This closes the question and rejects Option D. - Are there services outside this repo that reference
SyRF.WebHostConfig.Commonas a NuGet? If it's internally-consumed bycamaradesuk/*as a versioned package, splitting it is a breaking change that needs co-ordination. If it's ProjectReference-only inside this monorepo, the blast radius is contained. - Does the team want to preserve the "all services use the same bootstrap" property? Option A preserves it via mixins; Option C breaks it. The recommendation assumes "yes, preserve."
- Is this work in scope for the current sprint, or does it need a feature ticket + ZenHub tracking? The current PR #2591 gives us a place to discuss; execution may be a series of smaller PRs.
Out of scope for this analysis¶
- Replacing the shared-lib coverage problem structurally (one sonar project per lib vs per service). That's a separate SonarCloud configuration conversation.
- Migration to a non-Quartz scheduler (e.g., Hangfire, Temporal). The architectural smell is independent of the scheduler choice.
- Splitting
SyRF.SharedKernel— it's large but out of scope here.
Next action: Proceed with A + B (Option D rejected per the consumer analysis above). Slice 1 is Option B — turn it into actual commits on this branch.
Implementation outcome (2026-04-24)¶
Landed in PR #2591 as the following commits:
| Slice | Commit | What landed |
|---|---|---|
| Docs | a0fd6cf33 |
Planning-doc revision (A+B adopted, D rejected). |
| C | 8a6324a50 |
S3 types moved from SharedKernel → AppServices. |
| E | 9ed4ce69a |
Quartz Program.cs: removed AddSyrfMongoServices, DatabaseMetadataWriter lifetime hook, AppSettingsConfig placeholder. ExecuteInitAndStartupMethods switched to TryGetInstance<DatabaseMetadataWriter>. |
| D1 | 2d9d860ee |
MongoLamarRegistry made self-contained: binds MongoConnectionSettings / ConnectionStrings / SqlConnectionSettings via AddOptions<T>().BindConfiguration(...); registers IResumePointRepository, ChangeStreamHealthTracker, MongoUtils.EnsureLegacyGuidSerializer(). |
| D2 | 1c7d91c4a |
New SyRF.AppServices.FileServices.FileStorageRegistry with AddOptions<S3Settings>().BindConfiguration("S3Settings") + full S3/FileService DI. |
| D3+D4 | a88025a70 |
Deleted legacy AddSyrfMongoServices/AddSyrfDataServices/AddSyrfFileServices from SyrfConfigureServices.cs. Dropped SyRF.WebHostConfig.Common <ProjectReference> on SyRF.Mongo.Common and SyRF.AppServices. Added direct refs from API.Endpoint. API/PM/Quartz Program.cs now call IncludeRegistry<> directly. IEarlyInitTask + MongoInitHook adapter so ExecuteInitAndStartupMethods no longer imports DatabaseMetadataWriter. Quartz S3Settings placeholder deleted. SyRF.API.Helpers namespace renamed to SyRF.WebHostConfig.Common.Model (naming-bug fix). |
| F | this doc + ADR-008-co-located-di-registries.md |
Final-shape documentation. |
Build-time decoupling verified — a clean build of Quartz produces only:
SyRF.Quartz.dllSyRF.SharedKernel.dllSyRF.WebHostConfig.Common.dll
No SyRF.Mongo.Common.dll, no SyRF.AppServices.dll. SonarCloud's syrf-quartz scan scope shrinks accordingly.
Full solution test suite: 2,032 passed, 0 failed, 9 pre-existing skips.
Architectural pattern captured in ADR-008: Co-Located DI Registries.