Architecture Decision Record
ADR-016: Lockstep Package Versioning and the MassTransit-v8 Pin
Status
Accepted (2026-07-15). Amended (2026-07-28): the fitness function now gates two commercial-license
majors (MassTransit and SixLabors.ImageSharp), so the decision is restated as the pattern rather than
the single pin; the consumer framing is corrected (Store and ADC each declare a
MassTransit.Azure.ServiceBus.Core entry of their own for the Service Bus emulator test tier), as is
the claim that framework-to-app ([C->A]) changes are non-breaking. Amended (2026-08-01): Store now
has its own .github/dependabot.yml (added 2026-07-29) mirroring ADC's, so the trade-off that rested
on Store having no such file is restated, and the ADC MassTransit.Azure.ServiceBus.Core citation is
rebased onto its current lines. Amended (2026-08-14): every Directory.Packages.props citation is
rebased onto its current lines, and the versions are restated per repo (Common is on MassTransit
8.5.10 and SixLabors.ImageSharp 3.1.12; Store and ADC still declare
MassTransit.Azure.ServiceBus.Core 8.5.5). Amended (2026-08-28): a Transport exit options
section records the three candidates for an eventual move off MassTransit v8 and the one place a
candidate would be tried. Nothing about the pin, the gate or the dependency set changes. See also
ADR-101 (2026-08-29, v1.170.0): the MMCA.Common metapackage releases at
the same version off the same tag like every other package, so it is one more entry a consumer sweeps
in the same pass, and pinning six dependencies at its own version is only safe because of the lockstep
rule decided here. Amended (2026-08-31): Store's and ADC's own MassTransit.Azure.ServiceBus.Core
entries are 8.5.10, the same patch as Common's three MassTransit entries, which supersedes the 8.5.5
figure in the 2026-08-14 entry and retires the note that the app-side entry trailed within v8; the
Directory.Packages.props, DependencyInjection.cs and MessageBusSettings.cs citations are
rebased onto their current lines. The pin, the gate and the dependency set are unchanged.
Amended (2026-09-01): the emulator proving ground named in Transport exit options is authoritative
and deploy-gating in both consumers (ADC on 2026-08-31 as TD-17, Store immediately after), so the
"advisory by design so it can never gate a deploy" clause is restated; its fixture now ships as
ServiceBusEmulatorFixtureBase in MMCA.Common.Testing (v1.178.0), which is why the tier's
MassTransit v8 constraint is enforced in framework code rather than in two copies. The
using MassTransit surface in Transport exit options is recounted from source and restated as
eight files across two packages (the six that reference MassTransit types, plus the two that lower
the emulator's entity-quota defaults), and the MessageBusSettings.cs, IntegrationEventConsumer.cs,
UpcastingIntegrationEventConsumer.cs and Service-Bus-side DependencyInjection.cs citations are
rebased onto their current lines. The pin, the gate and the dependency set are unchanged.
Amended (2026-09-19): the using MassTransit surface in Transport exit options is recounted from
source and restated as nine files across two packages, eight of them in MMCA.Common.Infrastructure
(the added file is Messaging/Consumers/ConsumerOriginRestore.cs, which takes the consume context's
Headers), which supersedes the eight-file figure in the 2026-09-01 entry; the split between
type-referencing and quota-lowering files is corrected, because the emulator pair references
IServiceBusBusFactoryConfigurator and IBusControl in addition to setting the statics, so every
file in the set references a MassTransit type. The pin, the gate and the dependency set are
unchanged, and the three candidates are unchanged.
Revised 2026-10-01: the current-state text and citations are refreshed against source, with the
shared MassTransit patch at 8.5.11 in all three repos; the pin, the gate and the dependency set are
unchanged.
Revised 2026-10-06: the ordered replacement list in Transport exit options is ceded to ADR-118,
which accepted Wolverine first and a raw-SDK adapter second and rejects a commercial v9, so the three
2026-08-28 candidates remain only as the record of the first sketch.
Revised 2026-10-07: the emulator proving ground is restated as a five-day recency gate (a red
nightly blocks a deploy only once the last run green on both broker jobs is stale), and the Store
props and both cross-service-tests.yml anchors are rebased.
Context
MMCA.Common publishes its MMCA.Common.* NuGet package set (see FACTS.md for the authoritative
list and count) consumed by three downstream repos: the two
production apps (Store, ADC) and the reference seed Helpdesk (which defaults to local-source mode but
declares the same MMCA.Common.* package versions in its own Directory.Packages.props).
Two related governance questions had no recorded answer:
- How do the packages version and roll out? Independent per-package versions invite a
combinatorial compatibility matrix ("which
.Domainworks with which.Infrastructure?"), and a phased / opt-in rollout across consumers invites long-lived version skew between the apps and the framework. - Can dependencies float? A routine "update all NuGet packages" sweep repeatedly pulled
MassTransitto v9, which requires a commercial license (MT_LICENSE) and crashes every broker-enabled service at startup.
Decision
Version the whole
MMCA.Common.*package set in lockstep. All packages share one version (MinVer, derived from a singlevX.Y.Zgit tag); a release tags every package (seeFACTS.mdfor the authoritative list and count) at the same version. SemVer and the breaking-change policy live incommon-VERSIONING.mdindocs-src/guides/.Sweep every consumer in one pass, with no phased rollout. A framework change ships and all consumers bump every
MMCA.Common.*entry in theirDirectory.Packages.propstogether. The two production apps (Store, ADC) now commit NuGet lock files (RestorePackagesWithLockFile, R7/TD-01), so each one's sweep is a version bump plus a restore that regenerates its lock files (the sameaudit=all/--force-evaluatemechanics Common already uses); Helpdesk, which defaults to local-source mode, keeps no lock files. What makes the one-pass sweep safe is not that framework-to-app ([C->A]) changes are non-breaking: breaking changes ship deliberately, as MINOR bumps, and the version number is therefore not a breakage signal on its own (Website/docs-src/guides/common-VERSIONING.md:44-50). It is safe because every first-party caller moves in the same change set, an API removal is proven against Helpdesk's source build first (common-VERSIONING.md:69-70), and there is no[Obsolete]grace period to keep in step (common-VERSIONING.md:72-78).Pin a commercial-license dependency below its paid major, enforced by a fitness function.
DependencyVersionTestsBaseparsesDirectory.Packages.propsand fails the build when a pinned package reaches the major where its licensing changes (ADR-015). Two are gated today: MassTransit below major 9 (v9 demandsMT_LICENSEat startup, so every broker-enabled host crashes without one) andSixLabors.ImageSharpbelow major 4 (v4 demands a Six Labors key at BUILD time, so its MSBuild targets fail outright), each mirrored as a dependabot major-update ignore so the bump is never even proposed (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Governance/DependencyVersionTestsBase.cs:17-60,MMCA.Common/Directory.Packages.props:119-126,:134-144,MMCA.Common/.github/dependabot.yml:57-65). MassTransit is the original instance; ImageSharp is what showed the rule generalizes.The assertions run in MMCA.Common only, the one repo that subclasses the base (
MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Governance/DependencyVersionTests.cs:9). Helpdesk declares neither package. Store and ADC takeMassTransit,MassTransit.RabbitMQandMassTransit.Azure.ServiceBus.Coretransitively throughMMCA.Common.Infrastructure(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/MMCA.Common.Infrastructure.csproj:52-54), but each does declare one MassTransit entry of its own:MassTransit.Azure.ServiceBus.Core8.5.11 for the Service Bus emulator test tier, carrying a comment that names Common's lockstep v8 pin (MMCA.ADC/Directory.Packages.props:60-65,MMCA.Store/Directory.Packages.props:84-89). Common's own three MassTransit entries are on that same 8.5.11 (MMCA.Common/Directory.Packages.props:124-126), so all three repos sit on one patch version. Alignment there is a convention the app-side comments carry, not something the gate enforces (Store's comment names the lockstep pin without a patch number,MMCA.Store/Directory.Packages.props:85-86, so it holds across patch bumps): what the pin governs, and what the fitness function reads, is the major, so an app-side entry on a different v8 patch would still be inside the decision. They still do not subclass the test: its default list also names the two package ids they do not declare, and the rule fails on a pin it cannot find (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Governance/ArchitectureRules.Governance.cs:42-45).
Rationale
- One version, one compatibility story. Lockstep removes the N-package matrix: "everything on
vX.Y.Z" is the only supported combination, which is the right trade for a small team. - No skew. Sweeping all consumers at once keeps the framework and both apps converged rather than drifting across releases.
- A license pin is a build gate, not a comment. A blanket "update all packages" sweep does not read license terms. It pulled MassTransit to v9 once already, and because CI never starts a broker the build stayed green while every host was primed to crash at startup; ImageSharp v4 fails louder (the build itself) but arrives by exactly the same route. What stops both is the pair: the dependabot ignore keeps the proposal from being raised, and the fitness function catches it when a hand edit raises it anyway (which is exactly the invariant-over-discipline posture of ADR-015).
Trade-offs
- A consumer cannot adopt a single package in isolation: it takes the whole set at the new version.
- Lockstep will bump a package whose code did not change (acceptable: the version means "compatible with this set," not "this package changed").
- Each pin forgoes the newer major (MassTransit v9, ImageSharp v4) until a licensing decision is made; bumping one is a deliberate, multi-step change, not a one-line version edit.
- The gate reads MMCA.Common's
Directory.Packages.propsand nothing else, so theMassTransit.Azure.ServiceBus.Coreentry Store and ADC each declare for their emulator tier sits outside its reach. What holds those two at v8 is that neither repo lets dependabot touch NuGet at all: each carries adependabot.ymlscoped to the github-actions and docker ecosystems only (MMCA.ADC/.github/dependabot.yml:7,:28,MMCA.Store/.github/dependabot.yml:15,:32), whose own comment says NuGet is deliberately excluded becauseMMCA.Common.*bumps happen solely through this ADR's lockstep sweep and MassTransit must stay v8 (MMCA.ADC/.github/dependabot.yml:1-4,MMCA.Store/.github/dependabot.yml:6-8, the latter added 2026-07-29), plus review.
Transport exit options
The ordered list of what replaces MassTransit is owned by ADR-118 (Accepted 2026-09-11), which is the later decision and supersedes the candidate list below wherever the two differ: its decision 4 fixes the replacement order as Wolverine first and a raw-SDK adapter second, and its decision 5 rejects a commercial MassTransit v9 outright. This section keeps the surface inventory, the trial point and the proving ground, which ADR-118 relies on, and records the 2026-08-28 candidates as they were first sketched.
The pin has a horizon. MassTransit v8 is the free major and its community support ends at the end of 2026 (a maintainer statement about the package, not something this repository can assert), so "stay on v8" is a decision with an expiry rather than a permanent one. Nothing changes today; the candidates are recorded now so the eventual move is a comparison and not a scramble.
What makes any of them a bounded change is where MassTransit actually sits. The whole
using MassTransit surface is nine files across two packages. Eight are in
MMCA.Common.Infrastructure: Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:2,
Messaging/BrokerMessageBus.cs:2, Messaging/Consumers/IntegrationEventConsumer.cs:1,
Messaging/Consumers/IntegrationEventConsumerExtensions.cs:2,
Messaging/Consumers/UpcastingIntegrationEventConsumer.cs:3,
Messaging/Consumers/FaultIntegrationEventConsumer.cs:1,
Messaging/Consumers/ConsumerOriginRestore.cs:2 and Messaging/ServiceBusEmulatorSupport.cs:4;
the ninth is Source/Hosting/MMCA.Common.Testing/Fixtures/ServiceBusEmulatorFixtureBase.cs:5. The
last two are the emulator test tier rather than the transport: each one lowers the process-global
MassTransit.AzureServiceBusTransport.Defaults entity quotas the emulator rejects
(ServiceBusEmulatorSupport.cs:115-117, ServiceBusEmulatorFixtureBase.cs:74-76), a v8 constraint
that moves with that tier, not with the bus. Every one of the nine references a MassTransit type,
so none of them is an unused import: IConsumer<T>, ConsumeContext<T>, IPublishEndpoint and
IBusRegistrationConfigurator across the seven transport-side files, plus Fault<T>
(FaultIntegrationEventConsumer.cs:27) and an explicit implementation of
IEndpointRegistrationConfigurator (IntegrationEventConsumerExtensions.cs:189), where
ConsumerOriginRestore takes the consume context's Headers
(Messaging/Consumers/ConsumerOriginRestore.cs:35); the emulator pair adds
IServiceBusBusFactoryConfigurator (ServiceBusEmulatorSupport.cs:87) and IBusControl
(ServiceBusEmulatorFixtureBase.cs:68) on top of the quota statics.
Application, Domain and Shared never reference it:
IMessageBus is the Application-layer abstraction
(MMCA.Common/Source/Core/MMCA.Common.Application/Messaging/IMessageBus.cs:28) and
BrokerMessageBus is its only broker implementation (BrokerMessageBus.cs:43), which is the
boundary ADR-008's MicroserviceExtractionTests already keep enforced.
Three candidates as first sketched, none implemented and none evaluated against a running broker here (ADR-118 decision 5 has since rejected candidate 2, and its decision 4 ranks a raw-SDK adapter, the shape of candidate 3, second behind Wolverine):
- The OpenTransit community fork of MassTransit v8. Cheapest on paper, a package id swap under
the same API. No repo in this workspace references it, and the part that would decide it is Azure
Service Bus parity, since production runs Service Bus while local runs RabbitMQ
(
DependencyInjection.Messaging.cs:271,:302). Its release status and its parity are external facts this record cannot verify and does not assert. - A commercial MassTransit v9 license. The straight-line option: the pin exists only because v9
demands
MT_LICENSEat startup and every broker-enabled host crashes without it (MMCA.Common/Directory.Packages.props:119-123). A license retires the gate rather than routing around it, at a recurring cost. - A direct
Azure.Messaging.ServiceBusimplementation ofIMessageBus. The largest and the most owned. Publishing is one class (BrokerMessageBus.cs:43), but the consume side is where the library earns its keep: three consumers rideIConsumer<T>(IntegrationEventConsumer.cs:42,UpcastingIntegrationEventConsumer.cs:36,FaultIntegrationEventConsumer.cs:27) and the transport wiring supplies exponential in-process retry plus second-level delayed redelivery (DependencyInjection.Messaging.cs:279-296on RabbitMQ, where the redelivery half is opt-in behindEnableDelayedRedeliveryand off by default,:322-336on Service Bus, where it is unconditional, the two-level argument at:245-250), all of which would be hand-written. It also drops RabbitMQ, which the local Aspire stack provisions.
The trial point is the same for all three and it is additive: a new case in the MessageBusProvider
switch inside ConfigureBrokerTransport
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Messaging.cs:264, switch at :269,
enum at Messaging/MessageBusSettings.cs:236), so a candidate ships beside RabbitMQ and Service
Bus instead of replacing either, and a consumer opts in by configuration. The two artifacts that move
with a decision are the pin
(MMCA.Common/Directory.Packages.props:119-123, the three entries at :124-126) and the fitness
function that reads it
(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Governance/DependencyVersionTestsBase.cs:17-22,
the major ceiling at :24-37), plus the dependabot ignore they are paired with.
A proving ground already exists: both consumers' nightlies run an Azure Service Bus emulator smoke
against their real integration-event contracts, and the tier is authoritative rather than advisory
(MMCA.ADC/.github/workflows/cross-service-tests.yml:142, the gating rationale at :116-126,
and the equivalent servicebus-emulator-smoke job in
MMCA.Store/.github/workflows/cross-service-tests.yml:141, rationale at :121-135; the deploy-side
halves are recorded in ADR-066 and ADR-064). The deploy gate is a recency gate, not a per-run one:
cross-service-freshness requires the cross-service and servicebus-emulator-smoke jobs to have
both succeeded in one completed run of cross-service-tests.yml (whatever triggered it, so a
dispatched green run counts too) within window-days: '5'
(MMCA.ADC/.github/workflows/deploy.yml:883-888, MMCA.Store/.github/workflows/deploy.yml:838-843).
The proof's age is floored to whole days and the gate fails only when that age exceeds the window
(MMCA.Common/.github/actions/freshness-gate/action.yml:25-26, :143-145), so a red nightly blocks
a deploy only once the last run green on both jobs is six or more whole days old.
Any transport candidate has somewhere to be exercised that is not production.
Alternatives rejected
- Opt-in flags and compatibility shims for a framework change, with consumers upgraded per page or
per PR. Declined outright: no
virtualopt-in properties, no parallel old and new code paths, no sequencing that leaves a consumer half upgraded. The rejection was made concrete against a proposedUseQueryStringStateflag onDataGridListPageBase, which would have kept unconverted pages working until each was migrated; every consumer page in MMCA.ADC and MMCA.Store was updated in the same change instead. What removes the usual reason to phase a sweep is thelocal.propssource-mapping path, which makes a lockstep cross-repo change practical without a NuGet publish round trip. A half-upgraded codebase is the cost this record is spending lockstep releases to avoid, so re-introducing one per feature would defeat the decision above.
Revision (2026-10-01)
No decision, pin, gate or rationale changed: MassTransit stays below major 9 and ImageSharp below
major 4, and every repo still sits on one MassTransit patch. The current-state text is refreshed to
match source. The shared patch is now 8.5.11 in all three repos
(MMCA.Common/Directory.Packages.props:128-130, MMCA.ADC/Directory.Packages.props:65,
MMCA.Store/Directory.Packages.props:93), and Store's emulator-tier comment, which named 8.5.10, now names the pin
without a patch number (MMCA.Store/Directory.Packages.props:89-90). MMCA.Common.Infrastructure references all three
MassTransit packages, so MassTransit.Azure.ServiceBus.Core also flows transitively
(MMCA.Common.Infrastructure.csproj:50-52). The Store and ADC dependabot.yml files now also
manage the docker ecosystem, still excluding NuGet (MMCA.ADC/.github/dependabot.yml:28,
MMCA.Store/.github/dependabot.yml:32). The transport wiring lives in
DependencyInjection.Messaging.cs, and RabbitMQ second-level redelivery is opt-in behind
EnableDelayedRedelivery (default false), while Service Bus applies it unconditionally
(DependencyInjection.Messaging.cs:246-251, :277). The Directory.Packages.props,
dependabot.yml, MMCA.Common.Infrastructure.csproj, BrokerMessageBus.cs, consumer,
MessageBusSettings.cs, cross-service-tests.yml and common-VERSIONING.md citations are
rebased onto their current lines; the nine-file using MassTransit count is unchanged.
Revision (2026-10-06)
- Transport exit options and Related now cede the ordered replacement list to ADR-118 (Accepted 2026-09-11; decision 4 orders Wolverine first and a raw-SDK adapter second, decision 5 rejects a commercial v9), replacing the claim that the two lists are unreconciled and that neither was adopted. The three 2026-08-28 candidates stay as the record of the first sketch. The pin, the gate and the dependency set are unchanged.
- The MassTransit type list for the transport-side files now also names
Fault<T>(FaultIntegrationEventConsumer.cs:27) and the explicitIEndpointRegistrationConfiguratorimplementation (IntegrationEventConsumerExtensions.cs:189), which matter when sizing a hand-written adapter. - Facts recorded in the 2026-10-01 revision now sit at: the three MassTransit entries
MMCA.Common/Directory.Packages.props:124-126, theMMCA.Common.Infrastructure.csprojreferences at:52-54, and the redelivery posture inDependencyInjection.Messaging.cs:252-257, with the RabbitMQEnableDelayedRedeliverygate at:283. - Every live-section anchor (
Directory.Packages.props,MMCA.Common.Infrastructure.csproj,DependencyInjection.Messaging.cs,IntegrationEventConsumerExtensions.cs,ConsumerOriginRestore.cs) was re-verified against current source and rebased where it had moved.
Revision (2026-10-07)
Re-verified against current source. The pin, the gate and the dependency set are unchanged:
MassTransit stays below major 9, ImageSharp below major 4, and all three repos sit on MassTransit
8.5.11 (MMCA.Common/Directory.Packages.props:124-126, MMCA.ADC/Directory.Packages.props:65,
MMCA.Store/Directory.Packages.props:89). The gate itself did not change (window-days: '5' with
required-jobs-mode: same-run was already in place); what changed is this record's description
of it, plus anchors moved by the 2026-10-07 workflow pruning in MMCA.ADC #258 and MMCA.Store #197.
- Transport exit options no longer says a transport regression blocks the next deploy. The
cross-service-freshnessstep is a recency gate: it passes on the newest completed run ofcross-service-tests.yml(any trigger, so a dispatched green run counts) in whichcross-serviceandservicebus-emulator-smokeboth succeeded withinwindow-days: '5'(MMCA.ADC/.github/workflows/deploy.yml:883-888,MMCA.Store/.github/workflows/deploy.yml:838-843), with the age floored to whole days and compared as greater-than (MMCA.Common/.github/actions/freshness-gate/action.yml:25-26,:143-145), so a red nightly blocks a deploy only once that last proving run is six or more whole days old (MMCA.ADC/.github/workflows/deploy.yml:867,MMCA.Store/.github/workflows/deploy.yml:824). - Anchors rebased after MMCA.ADC #258 and MMCA.Store #197 (both "ci: prune unneeded workflow jobs
and steps", 2026-10-07) shifted them: Store's emulator-tier comment block
MMCA.Store/Directory.Packages.props:84-87(the patch-agnostic pin sentence at:85-86) and its entry at:89, previously:88-90and:93; the ADCservicebus-emulator-smokejobMMCA.ADC/.github/workflows/cross-service-tests.yml:142with its AUTHORITATIVE (TD-17) rationale at:116-126, previously:159and:132-143; the Store jobMMCA.Store/.github/workflows/cross-service-tests.yml:141with its rationale at:121-135, previously:160and:140-154. The ADC comment and entryMMCA.ADC/Directory.Packages.props:60-65are unchanged. The earlier anchors were exact when the 2026-10-01 and 2026-10-06 revisions recorded them.
Related
ADR-015 (the fitness function that enforces the pins), ADR-003 / ADR-006 (MassTransit is the broker transport behind the outbox and database-per-service flows). ADR-118 owns the exit strategy and the ordered replacement list (Wolverine first, a raw-SDK adapter second, a commercial v9 rejected); it is the later decision, so where its list and the 2026-08-28 candidates in Transport exit options differ, ADR-118 governs. No replacement is implemented yet.