Skip to content

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 infrastructure
  • SyRF.AppServices — S3 file service + related infrastructure
  • S3Settings / IS3PostSigner — with a placeholder registration in Program.cs so DI validation doesn't fail
  • DatabaseMetadataWriter → writes a heartbeat doc to the syrf_metadata Mongo collection on startup/shutdown

The symptoms this causes:

  1. SonarCloud scans Mongo/S3 library code under the syrf-quartz project. Coverage-exclusion workarounds were added on PR #2461 (now reverted) to silence false-negatives for MongoUnitOfWorkBase, 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.
  2. 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.
  3. Dockerfile build context bloat. Quartz's Docker build needs the whole monorepo because MSBuild has to resolve ProjectReferences it doesn't functionally need.
  4. 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_metadata care 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

Program.cs:85-94:

// 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

$ find src/services/quartz -name "*.cs" | xargs grep -l "IJob\b\|IJobConsumer<"
(no matches)

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:

  1. SyRF.WebHostConfig.Common is 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.
  2. SyrfRegistry (in SyRF.SharedKernel) uses unbounded convention scanning. It crawls every loaded SyRF assembly and auto-registers implementations via WithDefaultConventions(). There is no mechanism to say "I'm an SQL-only service, skip Mongo/S3 auto-wiring."
  3. DatabaseMetadataWriter is hard-wired to Mongo. The heartbeat telemetry concern (a generic cross-cutting need) is implemented only as a Mongo collection writer inside SyRF.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, OTEL
  • SyRF.Hosting.MassTransitConfigureSyrfMassTransit + RabbitMQ wiring
  • SyRF.Hosting.MongoAddSyrfMongoServices, Mongo health check
  • SyRF.Hosting.FileStorage — S3 client + file services
  • SyRF.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.Observability project (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.Observabilityrejected

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 IServiceHeartbeatSink contract (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_metadata is 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_metadata today 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:

  1. Remove the registry.AddSyrfMongoServices(...) call from Quartz's Program.cs.
  2. Remove the DatabaseMetadataWriter invocation (or replace with a SQL-based heartbeat, or drop heartbeat for Quartz).
  3. 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 S3Settings placeholder 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 AddSyrfMongoServices from Quartz's Program.cs and let the .csproj follow.

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

  1. Slice 1 (this PR) — Option B: scope SyrfRegistry scanning, remove S3Settings placeholder registration in Quartz. Pure-positive change, no breakage risk.
  2. Slice 2 — Option E (embedded in path to A): remove AddSyrfMongoServices + DatabaseMetadataWriter invocation from Quartz's Program.cs. Quartz no longer opens a Mongo connection at runtime. Quartz's observability continues via K8s probes + Elastic APM + Sentry + ArgoCD deploy notifications (unchanged).
  3. Slice 3 — Option A: split SyRF.WebHostConfig.Common into mixins (Hosting.Core, Hosting.MassTransit, Hosting.Mongo, Hosting.FileStorage, Hosting.Quartz). Quartz's .csproj loses the SyRF.WebHostConfig.Common reference and gains Hosting.Core + Hosting.MassTransit + Hosting.Quartz. Mongo assemblies stop being compiled with Quartz.
  4. 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.Core is more stable than SyRF.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 about IS3PostSigner.
  • Single Responsibility at the package level (Clean Architecture, "Package Cohesion" / REP-CCP-CRP trio): SyRF.WebHostConfig.Common violates 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)

  1. Is the syrf_metadata heartbeat 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.
  2. Are there services outside this repo that reference SyRF.WebHostConfig.Common as a NuGet? If it's internally-consumed by camaradesuk/* 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.
  3. 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."
  4. 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.dll
  • SyRF.SharedKernel.dll
  • SyRF.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.