Send the Mail Only When the Order Is Really Saved
Reacting to committed changes without a message bus
"When an order is shipped, mail the customer." The sentence is simple; where the code goes is not. Put it after SaveChanges() in the controller action and it fires for that one endpoint — not for the warehouse import that ships two hundred orders a night, not for the admin's bulk action. Put it inside the save, next to the rules in Save Consistently from Every Screen and Job, and it fires before the commit: a mail goes out for an order whose transaction then rolls back, and the customer is told about a shipment that never existed.
What the sentence asks for is a third place: code that runs once the change is committed, on every path that reaches the row, and never for a write that failed. Regira Entities does it for you with a reactor: an interceptor notes what each save changed and, after the commit, hands every changed row to the reactors registered for its type.
The reactor
A reactor is a class with two methods: whether it cares about a change, and what it does with it.
using Regira.Entities.Reactors.Abstractions;
public class OrderShippedReactor(IOrderMailer mailer) : EntityReactorBase<Order>
{
// an insert holding Shipped, or an update that brought the status there
public override bool CanReact(IEntityChange<Order> change)
=> change.ChangedTo(x => x.Status, OrderStatus.Shipped);
// change.Entity is the committed row, keys filled in; change.Original the stored row before the save
public override Task React(IEntityChange<Order> change, CancellationToken token = default)
=> mailer.EnqueueShipped(change.Entity.Id, change.Entity.CustomerEmail, token);
}
It is registered next to the entity's other rules, and the inline form covers the one-liners:
services.For<Order>(e =>
{
e.AddReactor<OrderShippedReactor>(); // class form, constructor-injected
e.React(x => x.Status, OrderStatus.Delivered, (change, sp, token) => // inline form
{
sp.GetRequiredService<ILogger<Order>>()
.LogInformation("Order {OrderId} delivered; was {Previous}", change.Entity.Id, change.Original?.Status);
return Task.CompletedTask;
});
});
IEntityChange<Order> is what makes the trigger precise:
Kind—Added,ModifiedorDeleted. A soft delete of anIArchivablerow isModified, andchange.ChangedTo(x => x.IsArchived, true)catches it.EntityandOriginal— the row as committed and the row as it was stored (nullfor an insert). Both are detached snapshots: navigations are not loaded, and changing them persists nothing.ChangedProperties,HasChanged(x => x.Status),ChangedTo(x => x.Status, value)— the first two report updates only (an insert or a delete changes the whole row, so testKind); the last one covers both an insert holding the value and an update that brought it there, which is the usual shape of "when it becomes X".
e.React(x => x.Status, OrderStatus.Shipped, …) is ChangedTo as a registration; a selector that is not a property fails at registration, not on the first commit. A reactor written against an interface — EntityReactorBase<IHasTimestamps> — reacts to every entity implementing it when registered with options.AddReactor<AuditReactor>(), and to one entity only through e.AddReactor<>().
How it runs
- After the commit, never before. For a save that commits on its own, at once; for the saves inside
BeginTransaction(), atCommit(); for aTransactionScope, when the completed scope is disposed. A failed save, a rollback or a transaction disposed without committing react to nothing. - In process, and the caller waits — the call that commits returns once they have run. So a reactor does not send the mail: it enqueues the job (
IBackgroundJobClient.Enqueue(...)on Hangfire, a message queue) and returns. The sample's mailer only logs a line, standing in for that enqueue. - A reactor that throws is logged and skipped. The data is committed, so the save still succeeds and the other reactors still run.
Run the sample (dotnet run, .NET 10 SDK, SQLite created and seeded on first start) and ship an order from one terminal while watching the console in the other:
URL=http://localhost:5330
# ship order 1: 200, and the console logs the mail after the row is committed
curl -s -X PUT $URL/orders/1 -H "Content-Type: application/json" \
-d '{"id":1,"customerName":"Acme Tools","customerEmail":"orders@acme.example","status":"Shipped"}'
# edit a shipped order: 200, no mail - the status did not change TO Shipped
curl -s -X PUT $URL/orders/1 -H "Content-Type: application/json" \
-d '{"id":1,"customerName":"Acme Tools & Co","customerEmail":"orders@acme.example","status":"Shipped"}'
# ship without an e-mail address: the prepper refuses, nothing is committed, no reactor runs
curl -s -i -X PUT $URL/orders/2 -H "Content-Type: application/json" \
-d '{"id":2,"customerName":"Bakkerij De Korenaar","customerEmail":"","status":"Shipped"}'
# HTTP/1.1 400 Bad Request
# {…,"errors":{"customerEmail":["A shipped order needs an e-mail address to notify."]},…}
# deliver order 1: the inline reactor logs the transition
curl -s -X PUT $URL/orders/1 -H "Content-Type: application/json" \
-d '{"id":1,"customerName":"Acme Tools & Co","customerEmail":"orders@acme.example","status":"Delivered"}'
Among EF Core's SQL lines, the console shows one line per reaction — after the first call and after the fourth, nothing for the two in between:
info: AfterCommitReactors.Orders.LoggingOrderMailer[0]
Mail: order 1 shipped -> orders@acme.example
info: AfterCommitReactors.Entities.Order[0]
Order 1 delivered; was Shipped
A Mail: order 3 shipped line appears before any of them, at start-up: the seeder inserts order 3 as Shipped, and an insert holding the value counts as a change to it.
The sample refuses the missing address in a prepper; a rule that only refuses belongs in a validator, which also stops the write before anything is tracked, so no reactor runs either.
Scope, and what it cannot see
- One DI scope per commit, not the request's: the commit's reactors share it and its fresh
DbContext. A reactor that writes callsSaveChanges()itself — anything it stages without saving rides along with the next reactor's save — and that save triggers the reactors of what it wrote. Nesting stops at eight levels with a logged error, so aCanReactthat ends the chain is part of the design. - Every
SaveChanges(), throughIEntityServiceor your own code on theDbContext, sync and async, as long as the context comes from DI (one younewup yourself is not wired).options.UseDefaults()wires it; without that, addDbContextWiring.Reactorstooptions.WireDbContext(...). What the interceptor never sees isExecuteUpdate/ExecuteDelete, which bypass the change tracker.
What stays a primer
The line between the two is atomicity. A history row that must exist if and only if the change exists, a computed column, a timestamp — anything that has to be part of the save — is a primer: it runs inside SaveChanges, in the same transaction, and rolls back with it. Anything that talks to the outside world is a reactor, because the outside world cannot be rolled back.
Four reactions you will actually write
| Reaction | Trigger | Shape |
|---|---|---|
| Mail or SMS on a status transition | e.React(x => x.Status, OrderStatus.Shipped, …) |
enqueue the job, return |
| Sync a row to another system (a CRM, a search index) | EntityReactorBase<Customer> reacting to every change |
enqueue with change.Entity.Id; the job re-reads the row |
| Invalidate a cache | a global EntityReactorBase<IEntity<int>> via options.AddReactor<>() |
evict by entity type and change.Entity.Id, in process |
| Follow-up workflow on a soft delete | change.ChangedTo(x => x.IsArchived, true) |
enqueue; the row is still stored, but default queries skip it, so the job searches with Archived = ArchivedFilter.Included |
Alternatives
| Option | Fits when | What it costs |
|---|---|---|
By hand: override SaveChangesAsync; before calling base, collect the ChangeTracker.Entries<Order>() whose status became Shipped, and enqueue their mails once it returns |
saves that commit on their own | inside BeginTransaction() the save returns before the commit, so a rolled-back order is still mailed; covering that takes a DbTransactionInterceptor and its TransactionCommittedAsync as well |
| An outbox: a primer writes the message row in the save's own transaction, and a relay delivers it | a lost mail is not acceptable, as with payments or legal notices: a reactor's mail is lost if the process dies between the commit and the enqueue | a table and a relay to run and watch, and a receiver that tolerates the duplicate an at-least-once relay sometimes sends |
| Domain events: MediatR notifications raised by the entity | a codebase already built around them | the same idea with more ceremony, and something still has to dispatch them after the commit |
Docs: Regira Entities. The sample runs on the free tier, without a license key.
Versions: reactors arrived in the Regira.Entities 6.4.0 packages and validators in 6.5.0, which also answers a refused write's 400 as a ProblemDetails, the field map under errors; the sample pins Regira.Entities.Web and Regira.Entities.Mapping.Mapster 6.6.0.