Architecture Decision Record
ADR-099: Generic Write-Side Entity Commands (Create, Update, Delete Without a Handler per Aggregate)
Status
Accepted (2026-08-29, framework v1.170.0). Extends
ADR-034, which gave every entity a generic read surface plus
create and delete, by completing the write half: a generic update command, its handler, a one-call DI
registration for all three verbs, and a controller base carrying the PUT. Additive throughout; no
existing controller, handler or registration changes. Revised 2026-08-30 (framework v1.172.0): five
surface extensions complete the record by closing the shapes that still forced a hand-written handler
(a mutation context carrying side data plus an idempotent-no-op short circuit, a payload-returning
mutate base, attempt-scope parity on the mutate path, an extensible DeleteEntityHandler, and a
verb-discriminated command with a command-aware applier). Still additive: every existing subclass,
command and applier compiles and behaves exactly as before. See the Revision at the end. Revised
2026-08-31: both consumer applications run on the extended surface from their own main, so the
closing note records adoption in production rather than a branch. Revised 2026-09-03 (framework
v1.177.0): AddEntityCrud also registers the update command's validator bridge, so a module's
update-request rules reach the generic command with no hand-registered pairing.
Context
ADR-034 records the read side of the generic resource layer and stops one verb short. Its
AggregateRootEntityControllerBase ships [HttpPost] create and [HttpDelete("{id}")]
(Source/Presentation/MMCA.Common.API/Controllers/AggregateRootEntityControllerBase.cs:59, :85),
and the framework already carried a generic delete command and handler. Update was the verb every
aggregate still hand-wrote.
That gap is small for an application with four aggregates and expensive for the shape this release
targets: a first module scaffolded from the template, where the aggregate is a title, a description,
a status and a child collection, and the update handler is the same twelve lines every time. The
shared load-mutate-save machinery already existed
(Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:52, whose
MutateCoreAsync at :271 loads the aggregate (:281), stamps the caller's concurrency token
(:291-292), runs the mutation (:294) and saves (:303)), so what was missing was not the
workflow but a command and a handler generic enough to close over any aggregate, plus somewhere for
the module to say which aggregate method a request maps to.
Two constraints shaped the answer.
The mutation cannot move into the framework. An update names fields, and a framework type that names fields is a framework type per aggregate. Worse, writing properties directly would route around the aggregate's guarded methods, which is where the invariants and the domain events live (ADR-083).
The four-parameter controller base is a shipped public surface. Adding an update-request type
parameter to AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest>
changes its generic arity, and every concrete controller in every consuming application stops
compiling on the next version bump: a source-breaking change to buy one action.
Decision
Ship the generic write side as four additive pieces plus a registration helper.
The aggregate keeps the mutation, behind one interface.
IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType>(Source/Core/MMCA.Common.Application/Interfaces/Mapping/IEntityDTOMapper.cs:79) has a single member,ApplyAsync(entity, request, cancellationToken)returningTask<Result>(:91). The module implements it by calling the aggregate's own guarded methods. It answers with a bareResultrather than a new entity because the instance handed in is the tracked one: a successful apply has already mutated it in place, and a refusal must leave it untouched so nothing reaches the database (:70-74). It is the write-side sibling of theIEntityDTOMapperandIEntityRequestMappera module already writes, and it is picked up by the sameScanModuleApplicationServicesscan.One generic update command.
UpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType>(Source/Core/MMCA.Common.Application/UseCases/Crud/UpdateEntityCommand.cs:48) carries the id, the request and the caller's last-observedRowVersion(:49-51). That token is a non-nullablebyte[]taken from the request'sIf-Matchheader rather than from the body (:44-47, ADR-035): an update request carries no token of its own, and a conditional write that states no precondition never reaches the handler.TEntityis a type parameter the command never otherwise uses: it distinguishes update handlers for two aggregates that share an identifier type, and it supplies the default cache prefix.- It implements
ICommandWithRequest<TUpdateRequest>(:52), so it is validated through aCommandRequestValidator<TCommand, TRequest>(Source/Core/MMCA.Common.Application/Validation/CommandRequestValidator.cs:30). The module scan's reflection bridge registers that pairing for every command a module assembly declares (Source/Core/MMCA.Common.Application/DependencyInjection.cs:254-270), and because this command is a closed generic constructed at registration time, which the scan cannot see,AddEntityCrudregisters the bridge for it explicitly (:351-353). A module writesIValidator<TUpdateRequest>and nothing else; the command is validated before the transaction opens, by the same Validating decorator every hand-written command goes through (ADR-014). - It implements
ICacheInvalidating(:52) with aCachePrefixdefaulting totypeof(TEntity).FullName + ":"(:65), the aggregate-prefix convention consumers already key cached reads under, because the generic controller constructs the command itself and cannot supply one. Setting it to an empty string opts out.
- It implements
One generic update handler, on the existing base.
UpdateEntityHandler<TEntity, TEntityDTO, TIdentifierType, TUpdateRequest>(Source/Core/MMCA.Common.Application/UseCases/Crud/UpdateEntityHandler.cs:48) derives from the DTO-returningMutateEntityHandlerBase(MutateEntityHandlerBase.cs:343) and overrides three members: the id (UpdateEntityHandler.cs:68), the row version (:76), andMutateAsync, which is a single delegation to the applier (:84-92). It is left unsealed so a module can subclass it to declare theIncludesa particular aggregate's mutation needs, or to add a[LoggerMessage]partial, without giving up the shared workflow (:15-21).HandlerNamereports the open handler name so aNotFoundfailure reads the same as the hand-written handler it replaces (:65). It raises no events of its own (:33-37): domain events belong to the aggregate's mutation methods, which the applier calls, and a handler that published anything would fire on the generic path and stay silent on a hand-written one (ADR-083).The create handler gets a concrete, hook-free form.
CreateEntityHandler<TCreateRequest, TEntity, TIdentifierType, TEntityDTO>(Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandler.cs:28) isCreateEntityHandlerBasewith none of its hooks overridden, and it issealedbecause the base, not this type, is the extension point (:14-19).One registration call per aggregate.
AddEntityCrud<TEntity, TEntityDTO, TIdentifierType, TCreateRequest, TUpdateRequest>()(Source/Core/MMCA.Common.Application/DependencyInjection.cs:331) registers the create, update and delete handlers closed over that aggregate's types (:339-349), plus the update command's validator bridge (:351-353), which the module scan cannot register because the command is a closed generic constructed here. Two properties are deliberate (:301-309):- Closed, not open-generic, because Scrutor's
TryDecoratewraps concrete service types: an openICommandHandler<,>registration would resolve completely undecorated andVerifyDecoratorPipeline()could not see it. TryAdd, notAdd, so an aggregate that outgrows one verb (a create needing a retry loop, a delete that must load its children first) registers its own handler for that verb before this call and keeps the generic pair for the other two.
It calls
ThrowIfPipelineSealed(:337), so registering afterAddApplicationDecorators()fails loudly rather than leaving three handlers unwrapped (ADR-014).- Closed, not open-generic, because Scrutor's
PUT ships on a new derived controller base, not on the shipped one.
CrudEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest, TUpdateRequest>(Source/Presentation/MMCA.Common.API/Controllers/CrudEntityControllerBase.cs:54) inheritsAggregateRootEntityControllerBaseand adds[HttpPut("{id}")](:88). A controller offering only create and delete keeps inheriting the four-parameter base; one that also offers update inherits this and gains the action (:24-29). The action is[Idempotent](:89, ADR-017) and[SupportsIfMatch](:90, ADR-035), so the PUT is conditional: the filter decodes the caller'sIf-Matchheader, refuses a request that states no precondition with 428, and the action reads the decoded token withSupportsIfMatchAttribute.RequiredToken(HttpContext)and hands it to the command (:103). A failed precondition is answered with 412 rather than 409. The request body carries no token. On success the refreshed token is emitted as a weakETagthrough the inheritedSetConcurrencyETag(:112), so a client can condition its next write without re-reading.
Rationale
- The one thing that varies per aggregate is the one thing the module writes. Load, concurrency
stamping,
NotFound, save, DTO projection, cache invalidation and validation are identical for every aggregate and now exist once; the field assignments are not, and they stay in the aggregate behindIEntityUpdateApplier. That is the same split ADR-034 made on the read side, where the module suppliesIEntityDTOMapperand inherits everything else. - Invariants and events keep exactly one home. The applier calls the aggregate's guarded methods,
so a generic PUT raises the same
{Entity}Changedevent with the same state discriminator a hand-written handler would (ADR-083), and a refused invariant stops the write before the save (MutateEntityHandlerBase.cs:294-296). - A new base beats a wider one. Adding a fifth type parameter to the shipped base would break every consumer's controllers at compile time in exchange for one action. Inheritance costs one word in a class declaration for the controllers that want the verb and nothing at all for those that do not.
- The command is a command, not a shortcut. Because it implements the two existing markers, it inherits the whole ADR-014 pipeline (feature gate, authorization, logging, caching, validation, timeout, transaction) rather than a parallel path with its own semantics. Nothing about a generic update is exempt from what a hand-written command gets.
TryAddmakes the helper partial-adoptable. Reaching for one bespoke handler does not mean abandoning the other two, which is the failure mode of an all-or-nothing scaffold.
Trade-offs
- The update request is the write contract, so it tracks the aggregate. Exactly the coupling ADR-034 records for the read side, on the other axis: what a caller may change is what the request exposes, and the applier is the only place that decides how far a request reaches into the aggregate.
- The default cache prefix is a convention, not a check.
typeof(TEntity).FullName + ":"is correct only for a module keying its cached reads under the aggregate prefix. A module that keys reads differently and does not setCachePrefixinvalidates nothing, and nothing fails: the same silent-staleness property the caching decorator has always had (ADR-026). AddEntityCrudregisters three handlers whether or not all three verbs are exposed. An aggregate with no delete endpoint still gets a delete handler in the container. It is inert, and the alternative is three parameters that would be wrong more often than the extra registration is.- Two controller bases now exist for one resource shape. A reader has to know which base carries which verbs, and the four-parameter one cannot be retired without the arity break this decision exists to avoid.
- The applier can be written badly. Nothing stops an implementation from assigning properties directly instead of calling the aggregate's guarded methods, which would skip the invariants and raise no events. No fitness rule checks it today; the interface makes the right thing easy, not the wrong thing impossible.
- Nothing in the framework opts a consumer in: every registration is a line the module writes.
Adoption is per aggregate and partial. ADC's Conference module registers it for
Category,ActivityandSponsor(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:143-145), Store's Catalog forProduct(one call per field-scoped update request) andCategory(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Application/DependencyInjection.cs:73-76,:77,:84), and Store's Identity forCustomer(MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/DependencyInjection.cs:66-68), each call placed after the convention scan soTryAddleaves the hand-written handlers those modules keep (the create verbs, and Catalog'sDeleteCategoryHandler) exactly where they were. An aggregate whose lifecycle is guarded rather than plain CRUD keeps its own handlers, and no migration is asked of anyone.
Related
ADR-034 (the read side plus create and delete this record
completes; its AggregateRootEntityControllerBase is what CrudEntityControllerBase derives from),
ADR-083 (the events the aggregate raises, which is why the
generic handler raises none),
ADR-035 (the RowVersion token, the required If-Match
precondition and the 412 the PUT honours),
ADR-017 (the [Idempotent] filter on the PUT, matching the generic
create),
ADR-014 (the decorator chain the command runs through, and the
sealed-pipeline guard AddEntityCrud respects),
ADR-013 (the Result the applier answers with and the failure mapping at
the edge),
ADR-026 (the prefix-keyed invalidation the command's CachePrefix drives),
ADR-001 (the IEntityDTOMapper the handler projects through).
Revision (2026-08-30): the five surfaces that complete the write side
The decision above shipped the generic update and left the bases where it found them. Reading real write handlers against those bases surfaced five shapes that still forced a hand-written copy of the load-mutate-save workflow, and in every case the reason was a missing extension point rather than a case the generic path was wrong for: a value derived before the mutation, an idempotent no-op, a fresh-scope retry, a delete that has to load its children or refuse, and a command that carries more than its request. All five are additive.
1. A mutation context on every write handler. MutationContext
(Source/Core/MMCA.Common.Application/UseCases/Crud/MutationContext.cs:31) is a per-command side
channel: a typed bag (Set at :56, TryGet at :69, GetOrDefault at :93, Contains at
:99) plus SkipSave() (:49) and the SaveSkipped flag it sets (:39).
MutateEntityHandlerCore creates one per run (MutateEntityHandlerBase.cs:98) and threads it through
LoadAsync (:172), MutateAsync (:136), LogMutated (:200) and OnMutatedAsync (:227), each
of them a new overload that forwards to the context-free one by default, so a handler that needs
neither keeps overriding what it always overrode and never sees the context. It exists because the
workflow answers with the mutated aggregate, so a value the mutation computed on the way (the
pre-mutation state, the blob the write is about to orphan) had nowhere to go except handler instance
state, which a scoped handler must not carry between calls (MutationContext.cs:12-17).
SkipSave() is read in the workflow itself (MutateEntityHandlerBase.cs:300-301): the command
returns the loaded aggregate as a success, with no save, no LogMutated and no OnMutatedAsync,
because an already-satisfied request (remove an avatar that is not there) is a success and must not
log a mutation that did not happen. The one behavioral note for anyone writing a new handler is that
MutateAsync(entity, command, token) is now virtual rather than abstract: a handler overrides
exactly one of the two overloads, and overriding neither throws at the call site naming the type
(:119-121).
2. A payload-returning mutate base.
MutateEntityPayloadHandlerBase<TCommand, TEntity, TIdentifierType, TResultPayload> (:388) is the
third mutate flavor beside the bare-Result one (:320) and the refreshed-DTO one (:343), for a
command whose response is a purpose-built envelope rather than the aggregate's DTO. TResultPayload
is unconstrained, and the subclass builds the answer in BuildResult(entity, command, context)
(:419), called only on success (:404-406), reading both the mutated aggregate and whatever the
mutation wrote into the context. That pair is the point: a pre-mutation value reaches the response
without handler instance state. It is a sibling type rather than a fourth type parameter on the DTO
flavor because generic types overload by arity alone and a four-parameter MutateEntityHandlerBase
already exists (:379-382).
3. Attempt-scope parity on the mutate path. MutateCoreAsync(attemptUnitOfWork, command, token)
(:255) and its context-taking overload (:271) take the unit of work as a parameter, exactly as the
create workflow's CreateCoreAsync already did
(Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:77). A handler whose
write can lose a unique-key race overrides HandleAsync, wraps the workflow in a retry loop and
runs each attempt against a fresh DI scope's unit of work, instead of reimplementing
load-stamp-mutate-save around the base. The parameter is load-bearing rather than cosmetic: the
ambient context still tracks the failed attempt, so a retry on the injected unit of work would never
persist (:247-250).
4. DeleteEntityHandler is opened. HandleAsync is virtual
(Source/Core/MMCA.Common.Application/UseCases/Crud/DeleteEntityHandler.cs:67) and the workflow is
split into Includes (:58), AsTracking (:64), LoadAsync (:101), the Result-returning
pre-delete hook OnDeletingAsync (:126), LogDeleted (:138), HandlerName (:50) and a
protected UnitOfWork (:43). The two things a real delete outgrows are both structural: the child
collections the aggregate's own Delete() cascade has to see, because an unloaded collection leaves
its rows live under a soft-deleted parent (:54-56), and an invariant that spans more than the
aggregate, which OnDeletingAsync refuses before Delete() is called and before anything is saved
(:76-78). With no Includes the load is the same bare by-id query the handler always issued
(:111-113), so an existing consumer sees no change. Events stay the aggregate's, as before
(:24-26).
5. A verb-discriminated command and a command-aware applier. Two related shapes, both about a command that the three-parameter form cannot express.
- Two verbs over one request DTO. The generic path keys the handler and its applier on (entity,
request, identifier), so an aggregate with two mutations that take the same request shape (an
inventory item increased or decreased by one
Quantitypayload) cannot close the command twice.UpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType, TApplier>(Source/Core/MMCA.Common.Application/UseCases/Crud/UpdateEntityCommand.cs:104) derives from the three-parameter command (:108) and adds the applier type as a phantom discriminator (:118, rationale at:75-82).UpdateEntityHandler<TEntity, TEntityDTO, TIdentifierType, TUpdateRequest, TApplier>(UpdateEntityHandler.cs:115) injects that applier by its concrete type (:117), which the module scan registers alongside its interfaces, and reports aHandlerNamenaming the verb so two verbs produce distinguishableNotFoundfailures (:133). Registration is oneAddEntityUpdateVerb<...>()per verb (DependencyInjection.cs:393),TryAddlikeAddEntityCrud(:401-405) and bridging the verb's command toIValidator<TUpdateRequest>(:407-409). The wire shape does not move: same route, same request DTO. - A command carrying state beside the request.
UpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType>is no longer sealed (UpdateEntityCommand.cs:48, rationale at:28-37), so a module derives a positional record that adds a route-derived child id, a server-decided flag or a second concurrency token while inheritingId,Request,RowVersion, theICommandWithRequestvalidator bridge and theCachePrefixdefault (:65). Those belong on the command rather than smuggled into the request DTO, where a caller could set them, so the applier has to see the command:IEntityUpdateCommandApplier<TEntity, TUpdateRequest, TIdentifierType, TCommand>(IEntityUpdateCommandApplier.cs:38) takes the whole command and the mutation context (:56-60) and still answers with a bareResultfor the same reason the request-only applier does (:29-32).UpdateEntityCommandHandler<TCommand, ...>(UpdateEntityHandler.cs:185) runs it on the shared workflow, delegating through the context-awareMutateAsync(:218-223), andAddEntityUpdate<TCommand, ...>()registers the pair (DependencyInjection.cs:444). - Both helpers call
ThrowIfPipelineSealed(:399,:450) likeAddEntityCrud, and both call the newAddCommandRequestValidator<TCommand, TRequest>()(:477): the explicit form of the bridge the module scan applies by reflection, for a command the scan cannot see because it is a closed generic constructed at registration time. It isTryAdd(:480), so an explicitIValidator<TCommand>still wins, and registering it with noIValidator<TRequest>present is harmless. - Post-load, pre-mutate work needs no new hook. A subclass that has to stamp
SetOriginalRowVersionon a tracked child row (ADR-035's second token, which the base's root-stampingRowVersionhook cannot reach) overridesMutateAsync, does its work againstUnitOfWork.GetRepository<TEntity, TIdentifierType>()and awaitsbase.MutateAsync(...), which is now documented on the handler itself (UpdateEntityHandler.cs:22-32).
The behavior is pinned in
Tests/Core/MMCA.Common.Application.Tests/UseCases/WriteSideExtensionsTests.cs, whose classes map one
to one onto the five: MutationContextTests (:16), MutationContextHandlerTests (:75),
MutateAttemptScopeTests (:185), DeleteEntityHandlerExtensionTests (:239),
VerbDiscriminatedUpdateTests (:333), DerivedUpdateCommandTests (:414) and
WriteSideRegistrationTests (:495).
Where the generic path stops
The extensions widen the shape the framework serves; they do not make the generic path the answer for every write. It covers a write whose shape is: load one aggregate, run its guarded methods, save. Four kinds of handler stay hand-written by design, and the boundary is where a write stops being that shape rather than where a handler happens to be long.
- Domain-verb state machines. An order that pays, cancels and delivers
(
MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Application/Orders/UseCases/Pay/PayOrderHandler.cs:13) is a transition set, not a field assignment: what a verb is allowed to do depends on the state the aggregate is in, and expressing that as a request DTO the caller fills in would put the state machine on the wire. - Sagas and payment flows. A handler that talks to a payment provider between the load and the
save
(
MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Application/Orders/UseCases/ProcessPaymentWebhook/ProcessPaymentWebhookHandler.cs:21) runs work that is not a mutation, with its own compensation and its own idempotency, inside a workflow whose entire contract is load-mutate-save. - The auth verticals. Password change, reset, external login and session revocation
(
MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordHandler.cs:18) mint tokens, hash credentials and send mail. Their side effects, not their aggregate writes, are the reason they exist. - Multi-aggregate orchestration. The generic workflow loads exactly one aggregate by id. A write that has to touch two roots is outside it by construction, and pretending otherwise would hide a transaction boundary inside a base class.
Nothing in the framework opts a consumer in, which is unchanged from the original decision: adoption
stays per aggregate, per verb, and partial, and TryAdd keeps a hand-written handler in place. Both
consumer applications run on these surfaces. MMCA.ADC serves its speaker update through a
command-aware applier
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/UseCases/Update/SpeakerUpdateApplier.cs:13-14)
registered with AddEntityUpdate
(.../MMCA.ADC.Conference.Application/DependencyInjection.cs:153); its session, event and room
writes sit on the payload base
(.../Sessions/UseCases/Update/UpdateSessionHandler.cs:23,
.../Events/UseCases/Update/UpdateEventHandler.cs:22,
.../Events/UseCases/AddRoom/AddRoomHandler.cs:28), as does the avatar write
(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:28),
and the avatar removal takes the mutation context on the bare-Result base
(.../Users/UseCases/RemoveUserAvatar/RemoveUserAvatarHandler.cs:20, :32). MMCA.Store registers
the verb discriminator for the two inventory verbs that share one request DTO
(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Application/DependencyInjection.cs:78-79). Those
writes run the generic path in production; the four kinds listed above are what stays outside it.
What these cost
- The context is a string-keyed, untyped bag. A key typo reads as absent and a type mismatch reads
as absent (
MutationContext.cs:73), so a writer and a reader that disagree fail quietly rather than at compile time. It is also deliberately not thread-safe (:26-29). MutateAsyncmoved fromabstracttovirtual, which trades a compile error for a runtime throw when a new handler overrides neither overload (MutateEntityHandlerBase.cs:119-121).SkipSavereturns success on a command that wrote nothing. A caller reading a 2xx as proof a write happened is now wrong, and the response is deliberately indistinguishable from one that did write.- Three mutate bases now exist where there were two, and choosing between them is a reader's problem before it is a writer's.
- The verb discriminator is a phantom type parameter. It buys two commands over one request DTO at
the price of a type name that carries an applier name into log lines and failure sources
(
UpdateEntityHandler.cs:133). - A
virtual HandleAsynconDeleteEntityHandlerallows a subclass to replace the workflow entirely, not just extend it, andIncludesis a string collection resolved at query time, so a renamed navigation property fails on a request rather than in a build.