Architecture Decision Record
ADR-083: One CRUD Lifecycle Event per Entity with a State Discriminator
Status
Accepted (2026-08-14).
Context
ADR-003 decides how a domain event moves: captured into the outbox inside SaveChangesAsync,
dispatched in-process after commit, or published to the broker when it is an integration event.
ADR-010 decides how a cross-boundary event contract evolves. Neither decides what an event is,
and that question lands on every new aggregate on its first day: does creating, editing and
soft-deleting a Session raise SessionCreated, SessionChanged and SessionDeleted, or one event
that says which of the three happened?
The {Entity}Created / {Entity}Changed / {Entity}Deleted triple is the default answer in most DDD
samples, and it scales badly in a modular monolith: three record types, three Scrutor-scanned handler
registrations and three serialized outbox payload shapes per entity, so a module with eight aggregates
carries twenty-four event types before a single business rule is expressed. It also splits the
subscriber's view. A handler that wants "something happened to this session" implements three
interfaces and has to keep three implementations in agreement, and adding a fourth transition later
means touching all of them.
Decision
Every generic CRUD lifecycle transition of an entity raises one event type for that entity,
carrying a DomainEntityState discriminator; handlers filter on State.
- One base record, two members.
EntityChangedEvent<TIdentifierType>(DomainEntityState State, TIdentifierType EntityId) : BaseDomainEvent, constrainedwhere TIdentifierType : notnull(MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/EntityChangedEvent.cs:24-27). Its own XML doc states the intent: consolidate the{Entity}Created/{Entity}Changed/{Entity}Deletedpattern "into a single event type per entity" (:6-8). Deriving fromBaseDomainEventmeans every lifecycle event inherits theMessageId+DateOccurredenvelope with no extra members (.../DomainEvents/BaseDomainEvent.cs:28,35). - The discriminator is a four-member enum, three of which are ever raised.
DomainEntityStateisUnchanged = 0,Added = 1,Updated = 2,Deleted = 3(MMCA.Common/Source/Core/MMCA.Common.Domain/Enums/DomainEntityState.cs:7-13). No production call site in any repo raisesUnchanged: it is the zero default, and it appears only as a negative[InlineData]case in handler tests (MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Application.Tests/Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandlerTests.cs:45,60,MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/DomainEvents/SessionCreatedHandlerTests.cs:40). Addedfrom the factory,Updatedfrom mutators,DeletedfromDelete(). The base's usage note fixes the mapping (EntityChangedEvent.cs:10-13), andSessionis the canonical shape: one event type, three raise sites, inMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:206(Added, from the static factory),:267(Updated),:304(Deleted, inside the soft delete), all constructing the sameSessionChanged(.../Sessions/DomainEvents/SessionChanged.cs:13-18).- Handlers filter on
State, or deliberately do not.SessionCreatedHandlersubscribes toSessionChangedand returns immediately unless the state isAdded(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/DomainEventHandlers/SessionCreatedHandler.cs:17-18), and the points award for asking a question does the same onSessionQuestionChanged(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandler.cs:48-49). A handler that genuinely wants every transition writes no filter and logs the discriminator instead:TicketChangedAuditHandlerpassesdomainEvent.Statestraight into itsLoggerMessagetemplate (MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/Tickets/DomainEventHandlers/TicketChangedAuditHandler.cs:23,28-29). - Business state-machine transitions keep their own event types. The base's doc scopes it to
generic CRUD and directs events such as
OrderPaidandShoppingCartCheckedOutto inheritBaseDomainEventdirectly (EntityChangedEvent.cs:16-19), which is what they do:OrderPaidcarries a customer, a frozen total and an order-line snapshot (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Domain/Orders/DomainEvents/OrderPaid.cs:13-18), andShoppingCartCheckedOutnames the checkout transition rather than an update (.../Sales.Domain/ShoppingCarts/DomainEvents/ShoppingCartCheckedOut.cs:6-8, raised at.../ShoppingCarts/ShoppingCart.cs:115). The test is payload plus intent: a transition with a name a business person uses and fields no other transition carries gets its own type. - The discriminator rides the wire, and it is frozen there. Store's one cross-module contract puts
DomainEntityState Statefirst on aBaseIntegrationEvent(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Shared/Products/IntegrationEvents/ProductVariantChanged.cs:26-32) and explicitly consolidates four former events,ProductVariantAdded,ProductVariantRemoved,ProductVariantSkuChangedandProductVariantPriceChanged(:8-10). The Sales consumer filters it toAdded(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Application/Inventory/DomainEventHandlers/ProductVariantAddedHandler.cs:37-38). Because integration-event shapes are snapshot-frozen by an architecture test (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Events.cs:45-58),State:DomainEntityStateis a committed line of the wire contract (MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/IntegrationEventContractTests.cs:11), so retyping or removing the discriminator fails the build and, under ADR-010, requires a new event type rather than a silent reshape. - The shared base is the convenience; the discriminator shape is the convention. Thirteen concrete
records derive
EntityChangedEvent<TId>across the four repos: six in Store (OrderChanged.cs:19,ShoppingCartChanged.cs:16,InventoryItemChanged.cs:17underSales.Domain,Catalog.Domain/Products/DomainEvents/ProductChanged.cs:23,Catalog.Domain/Categories/DomainEvents/CategoryChanged.cs:19,Identity.Domain/Customers/DomainEvents/CustomerChanged.cs:25), six in ADC Conference (SponsorChanged.cs:16,EventChanged.cs:16,QuestionChanged.cs:16,CategoryChanged.cs:16,SpeakerChanged.cs:21,SessionChanged.cs:18), and one in Helpdesk (TicketChanged.cs:15). Nineteen further event types follow the same one-event-with-Stateshape while inheritingBaseDomainEventorBaseIntegrationEventdirectly, either because they identify a parent/child pair rather than a single entity (RoomChangedcarriesEventId+RoomId,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/DomainEvents/RoomChanged.cs:13-18;ShoppingCartItemChangedcarriesCustomerId+ProductVariantId,MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Domain/ShoppingCarts/DomainEvents/ShoppingCartItemChanged.cs:16-22) or because the module took the shape without the base (all seven ADC Engagement events, for exampleMMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/UserSessionBookmarks/DomainEvents/UserSessionBookmarkChanged.cs:15-20, whose doc cites the same rule as BR-60 at:8-9). Counting the shape rather than the base type: 22 in ADC, 9 in Store, 1 in Helpdesk. - Nothing enforces the taxonomy. The shared fitness rules require domain events to be sealed and to
live in a
*.DomainEventsnamespace (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Naming.cs:52-60), to be immutable (ArchitectureRules.Immutability.cs:34-38), and require integration events to inheritBaseIntegrationEventand declare anint SchemaVersion(ArchitectureRules.Events.cs:6-25), but no rule mentionsEntityChangedEventor the discriminator. The framework's own coverage of the base is five unit tests over two test doubles, oneint-keyed and oneGuid-keyed (MMCA.Common/Tests/Core/MMCA.Common.Domain.Tests/DomainEvents/EntityChangedEventTests.cs:10-59, doubles at:63-69).
The seed ships with the pattern, so an adopter starts on it. Helpdesk's TicketChanged is the
reference adopter
(MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Domain/Tickets/DomainEvents/TicketChanged.cs:12-15),
and because the MMCA.Templates pack is staged from that tree rather than from a copy (ADR-065), it is
also what dotnet new mmca-app hands over. The seed also shows the one hole in "Added from the
factory": Ticket's identifier is database-generated, so it is still 0 at factory time and the
aggregate deliberately raises no Added event, with creation signalled after commit by a separate
integration event instead
(.../Tickets.Domain/Tickets/Ticket.cs:81-84, published at
.../Tickets.Application/Tickets/UseCases/Create/CreateTicketHandler.cs:46); the audit handler's doc
records exactly that gap (TicketChangedAuditHandler.cs:13-14). Outside the four repos, the two-module
MMCA.ECommerce companion sample carries two more adopters on the same pattern
(MMCA.ECommerce/Source/Modules/Products/MMCA.ECommerce.Products.Domain/Products/DomainEvents/ProductChanged.cs:15,
.../Orders/MMCA.ECommerce.Orders.Domain/Orders/DomainEvents/OrderChanged.cs:15), which brings the
total number of records deriving the base to 15.
Rationale
- One type per entity is one subscription surface. A subscriber declares interest in the entity, then decides which transitions matter, instead of the container deciding for it across three registrations that can drift apart.
- The triple multiplies types without adding information.
SessionCreated,SessionChangedandSessionDeletedwould carry the same identifier and the same fields; the only thing that differs is the verb, which is precisely what an enum member expresses. - The shape survives extraction. The same record is the in-process domain event in the monolith and the serialized outbox payload once the module runs as a service (ADR-003 / ADR-008). Collapsing three types into one shrinks the contract surface a consumer must learn and the wire snapshot must freeze.
- The carve-out keeps the model honest. A CRUD discriminator is the right answer for "a row
changed" and the wrong answer for "payment cleared":
OrderPaidcarries an order-line snapshot precisely so downstream handlers do not re-query inside an uncommitted transaction (OrderPaid.cs:10-12). Folding that into a lifecycle base would hang a nullable business payload off every entity's event. - Adding a transition is cheap. A new lifecycle state is an enum member plus handler branches, not a new record, a new registration and a new payload shape.
Trade-offs
- Every selective handler pays a filter. A handler that cares about one transition has to open with
a
Stateguard and return (SessionCreatedHandler.cs:17-18is the shape to copy); omit it and the handler fires on all three. The compiler cannot help, because the wrong behavior is an extra silent invocation, not a build error. - The subscription surface is coarser. Subscribing means subscribing to the whole entity lifecycle: the dispatcher invokes every subscriber on every transition and the handler decides. With separate types the container would only ever call a creation handler on creation. Granularity is per entity, not per transition.
- The business-event line is a judgment call, not a rule. Nothing checks whether a transition
deserves its own type, and
LivePollChangedsits right on the line: it folds Created/Opened/Closed/Deleted into one event and carries the poll'sLivePollStatusso handlers can tell the transitions apart (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/DomainEvents/LivePollChanged.cs:9-11,17-22), which is a state machine expressed through the CRUD shape rather than as its own events. - The base type is optional in practice. 13 of the 32 lifecycle events across the three apps derive
EntityChangedEvent<TId>; the other 19 re-declare the same two members onBaseDomainEvent. Consistency is a review convention, not a fitness function (ADR-015), so a new module can drift without a failing test. - A padded payload where an entity only ever does one thing.
PointsEntryChangedcarries aStatethat is alwaysAddedbecause the ledger is append-only, and its doc says so (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Points/DomainEvents/PointsEntryChanged.cs:8-10,17-23). The uniform shape wins over a slimmer event. Unchangedis reachable and meaningless. It is the enum's zero value, so a default-constructed or partially deserialized event reads asUnchangedrather than failing loudly. Handlers that filter positively for the state they want are unaffected; a handler written as a two-branch test overAddedversus everything else would silently treat it as the second branch.- A wire discriminator is a versioning obligation. Once
Stateis in the frozen contract (IntegrationEventContractTests.cs:11), the enum's member values are part of the payload: adding a member is additive, but renumbering or removing one is a breaking change under ADR-010.
Related
ADR-003 (how these events are captured and dispatched; this ADR decides only their shape), ADR-010
(schema versioning for the discriminator once it crosses a service boundary), ADR-008 (the extraction
that turns the same record into a wire payload), ADR-015 (fitness functions: the taxonomy is
deliberately not one of them), ADR-021 (consumer-side dedup on the MessageId this base inherits),
ADR-065 (the template pack that ships the pattern to adopters).