Architecture Decision Record
ADR-010: Integration-Event Schema Versioning & Upcaster Policy
Status
Accepted (2026-06-19). Updated 2026-06-27 (Helpdesk enforcement gap closed; all three consumers now gate the convention). Updated 2026-08-14 (ADC now gates seven events, and a fourth tree, the local MMCA.ECommerce sample, subclasses the same base). Revised 2026-08-18 (MMCA.Common now ships its own concrete integration event, OutputCacheEvictionRequested, so the framework's convention test is no longer vacuous: enforcement runs at five points, not four). Updated 2026-08-21: the upcaster registration extension point named below as follow-up work now ships; see ADR-090. Amended 2026-09-11: payload purity is now part of the contract and is build-gated, so an integration event may neither ship from outside a *.Shared assembly nor expose a type declared in a *.Domain assembly on its wire shape (see the amendment section at the end). Updated 2026-09-19 (Store now ships four concrete integration events, not one: ProductVariantChanged, ProductInfoChanged, OrderFulfilled and CustomerErased).
Context
Integration events cross service boundaries (Identity → Conference, Conference ↔ Engagement, …) and
are resolved by consumers solely by their type string: the outbox serializes the event to JSON keyed
by EventType (OutboxMessage.FromDomainEvent), and the MassTransit broker path binds by .NET message
type. Events carried only MessageId (idempotency) and DateOccurred (when the business action
happened): no version signal. With database-per-service (ADR-006) and async integration over the
outbox (ADR-003), a producer that reshapes an event's payload can silently break every consumer: there
is nothing on the wire that says "this is a different shape than you expect," and no agreed rule for how
a shape may evolve. Rubric §6 flags this as the one substantive CQRS/event gap.
Decision
- Every integration event carries an explicit
SchemaVersion.BaseIntegrationEventexposespublic virtual int SchemaVersion => 1;. It is serialized with the payload (System.Text.Json on the outbox path, MassTransit on the broker path), so a consumer always sees the producer's declared version. A fitness function asserts every concreteIIntegrationEventdeclares anint SchemaVersion, so a new event cannot ship without one. The rule body (ArchitectureRules.IntegrationEventsDeclareSchemaVersion) lives once in the sharedMMCA.Common.Testing.Architecturepackage and is surfaced throughEventConventionTestsBase, alongside two companion rules: every integration event inheritsBaseIntegrationEventand resides in a Shared-layer*.IntegrationEventsnamespace. - Additive, optional changes keep the same version. Adding a nullable/optional field, or a field with a safe default, is backward-compatible: consumers ignore unknown fields (System.Text.Json default) and old payloads deserialize with the default. No version bump required.
- Breaking changes require a new type + an upcaster, never a silent reshape. Renaming, removing,
or retyping a field is breaking. The producer introduces a NEW event type (e.g.
UserRegisteredV2,SchemaVersion => 2) and publishes it; consumers register an upcaster that maps the old type/version to the new shape before their handler runs. The old type is retired only after all consumers have drained it (the broker binds by type, so a rename is a parallel-publish/drain migration, not an in-place edit). - Rollout follows the framework's lockstep policy. Adding
SchemaVersionis a non-breaking[C→A]change (virtual default → existing events stay version 1 with no edits); it ships in a Common release and is swept into all consumers in one pass.
Rationale
- A signal, enforced. A version field plus a build-gating convention test turns "remember the contract" into something the tooling checks: the same invariant-over-discipline approach as the layer rules, the MassTransit-v8 pin, and ADR-009's resilience gate.
- Non-breaking by construction. A
virtualget-only default (=> 1) means no existing event changes and no outbox row migrates: System.Text.Json tolerates the missing field on old payloads and the type supplies the default; new rows simply gain"SchemaVersion":1(the outbox serializer options set no naming policy, so outbox payloads keep PascalCase:MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxMessage.cs:17-20,:142). - New-type-for-breaking-change is the only safe option when transport binds by type (MassTransit) and consumers are independently deployed: an in-place reshape has no compatibility window.
Trade-offs
SchemaVersionis a signal, not a mechanism: by itself it does not stop a consumer breaking on a real reshape. The load-bearing half is the discipline (new type + upcaster). At the time this record was accepted the framework shipped no upcaster registration extension point and the policy was enforced by convention + review; that follow-up closed on 2026-08-21 with ADR-090:IEventUpcaster<TSource, TTarget>+AddEventUpcaster<...>()+RegisterUpcastedIntegrationEventConsumer<TOld>(), applied on both delivery paths, with two new fitness functions onEventConventionTestsBasegating upcaster shape.- The convention test is no longer vacuous in MMCA.Common: the framework now ships one concrete
integration event,
OutputCacheEvictionRequested(Source/Core/MMCA.Common.Domain/IntegrationEvents/OutputCacheEvictionRequested.cs:29), a sealed record inheritingBaseIntegrationEvent, andCommonArchitectureMapregisters the Domain assembly it lives in (Tests/Architecture/MMCA.Common.Architecture.Tests/CommonArchitectureMap.cs:22), soEventVersioningConventionTestsgates a real event in Common's own build. Enforcement therefore runs at five points, not four. (Common's map declares no modules, so the namespace rule's Shared-layer half is relaxed there:ArchitectureRules.IntegrationEventsResideInSharedIntegrationEventsNamespace(Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Contracts/ArchitectureRules.Events.cs:28-33) applies thesharedAssembliescheck only whenmap.ModuleNames.Count > 0, which is why the framework's own event may sit in a Domain-assembly*.IntegrationEventsnamespace while every module-bearing consumer is still held to Shared.) The other four are the consumer trees, which subclassEventConventionTestsBaseand run the identical rules against their own event assemblies:EventConventionTestsinMMCA.ADC.Architecture.Tests(ADC's seven events, spread across the Identity, Conference and Engagement Shared assemblies the map registers) andMMCA.Store.Architecture.Tests(Tests/Architecture/MMCA.Store.Architecture.Tests/Contracts/EventConventionTests.cs:3, Store's four events across three modules:ProductVariantChanged(Source/Modules/Catalog/MMCA.Store.Catalog.Shared/Products/IntegrationEvents/ProductVariantChanged.cs:41) andProductInfoChanged(Source/Modules/Catalog/MMCA.Store.Catalog.Shared/Products/IntegrationEvents/ProductInfoChanged.cs:31) in Catalog Shared,OrderFulfilled(Source/Modules/Sales/MMCA.Store.Sales.Shared/Orders/IntegrationEvents/OrderFulfilled.cs:28) in Sales Shared, andCustomerErased(Source/Modules/Identity/MMCA.Store.Identity.Shared/Customers/IntegrationEvents/CustomerErased.cs:22) in Identity Shared), a matchingEventConventionTestsinMMCA.Helpdesk.Architecture.Tests(ArchitectureTests.cs) that gates the seed'sTicketOpenedIntegrationEvent, and one in the two-moduleMMCA.ECommercesample (Tests/Architecture/MMCA.ECommerce.Architecture.Tests/ArchitectureTests.cs:38, a local-only sample repo that is not published) gating itsProductCreatedIntegrationEventandOrderPlacedIntegrationEvent. The earlier Helpdesk gap (the rule was once subclassed only in ADC and Store) is closed: every concrete integration event across the four consumer trees is enforced, and so is the framework's own. - A get-only
SchemaVersionis informational on the wire (it round-trips out, not back in): intentional (version is a property of the type, not per-instance data), but it means you read it off the concrete type/JSON, not by mutating it.
Amendment (2026-09-11): integration-event payload purity
Versioning answers "how may this contract change". It does not answer "what may be in it in the
first place", and that gap is where the more expensive failure lives. An event whose property is
typed as a domain entity or a domain value object exports the producer's internal model to every
consumer: an ordinary internal refactor becomes a cross-service breaking change, and fields the
producer never meant to publish ride along on the wire. A SchemaVersion on such an event is a
version number attached to something that was never a contract.
The rule. Integration events are the public contract; domain events are not the public API. Therefore:
- Every concrete
IIntegrationEventimplementor is declared in a*.Sharedassembly. A consuming module may reference only Shared, so an event declared anywhere else is a contract nobody is allowed to reference. - No public property on an integration event reaches a type declared in a
*.Domainassembly, directly or through a nested payload record this repo declares. Project the value onto a primitive or onto a contract record in Shared instead.
Enforcement. IntegrationEventPayloadPurityTestsBase
(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Contracts/IntegrationEventPayloadPurityTestsBase.cs:33)
exposes the two rules as IntegrationEvents_ShouldShipFrom_SharedAssemblies (:38) and
IntegrationEventPayloads_ShouldNotExpose_DomainTypes (:41), over the rule bodies in
Rules/Contracts/ArchitectureRules.IntegrationEventPurity.cs
(IntegrationEventsLiveInSharedAssemblies at :18, IntegrationEventPayloadsAreDomainFree at
:56). The payload walk recurses through payload types the repo itself declares, because burying an
entity one level down hides the leak without removing it; it stops at framework and BCL types, which
are not the repo's to police, and it unwraps arrays, nullables and generic collection arguments, so
IReadOnlyList<OrderLine> is judged on OrderLine
(UnwrapTypeArguments at .../ArchitectureRules.IntegrationEventPurity.cs:133-148, called from the
walk at :101). Recursion is bounded by a visited set (:94), so a
payload record that refers back to itself terminates. Only the residency rule is vacuous for a
module-less map (:22-25): MMCA.Common is the framework rather than a module, so its own shipped
event answers to the public API baseline rather than to a module's Shared assembly. The payload rule
has no such guard and walks OutputCacheEvictionRequested in Common's own build
(MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Contracts/IntegrationEventPayloadPurityTests.cs:11-15, :72-75).
Why the type system alone was not enough. BaseIntegrationEvent still derives from
BaseDomainEvent (MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseIntegrationEvent.cs:11),
which is what lets one outbox row and one dispatcher carry both kinds of event, and IEventBus
already accepts only IIntegrationEvent on both of its overloads
(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Events/IEventBus.cs:18, :25). Those
two facts together stop a domain event from being published outward. What they cannot stop is a
domain type travelling inside a properly typed integration event, which is a payload question
rather than an envelope question. The compiler gates the envelope; these rules gate the payload.
Not in scope: renaming. Existing event names are unchanged. The XChanged naming is a
deliberate decision on its own axis and nothing in this amendment revisits it; a purity fix projects
a property onto a contract type, it does not rename the event carrying it. A rename would be the
new-type-plus-upcaster migration this record already governs
(ADR-090), which is a far larger act than the one this
amendment asks for.
Revision (2026-10-01)
No decision or rationale changed; two statements and one citation were corrected against source.
The amendment claimed both purity rules are vacuous for a module-less map; only the residency rule
returns early when map.ModuleNames.Count == 0
(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Contracts/ArchitectureRules.IntegrationEventPurity.cs:22-25),
while IntegrationEventPayloadsAreDomainFree (:56-78) walks the framework's own event. The
unwrapping citation moved from :89 (the recursive walk) to UnwrapTypeArguments (:133-148). The
Rationale's outbox example now shows "SchemaVersion":1, because the outbox serializer options set
only ReferenceHandler.IgnoreCycles and no naming policy
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxMessage.cs:17-20, :140).
Revision (2026-10-06)
No decision, rationale or content changed; one citation moved.
- The outbox serialization call (
JsonSerializer.Serialize(domainEvent, type, SerializerOptions)) now sits atMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxMessage.cs:142, not:140; the Rationale citation is updated (the 2026-10-01 Revision keeps:140as recorded on that date). The serializer options at:17-20are unchanged. - The Context's "type string" is, in current code, the stored name from
EventNameResolver.GetStorageName(OutboxMessage.cs:141): the[EventName]identity when the event declares one, otherwise the assembly-qualified name. That identity is governed by ADR-003; the Context is left as decision-time framing. - All other anchors in the live sections were re-verified against current source and still hold.