← All articles

Send the Mail Only When the Order Is Really Saved

Reacting to committed changes without a message bus

When an order ships, the customer gets an e-mail, whichever screen, import or job shipped it, and never for a save that failed halfway. One small class does it, running once the change is stored for good.
Send the Mail Only When the Order Is Really Saved

"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 write path with the reactor at the end: the request goes through the prepper and SaveChanges runs the primers; only when the transaction commits do the reactors run, in one DI scope per commit, and the mailer enqueues the job. A save the prepper refuses, or a transaction that rolls back, never reaches the reactors.

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, Modified or Deleted. A soft delete of an IArchivable row is Modified, and change.ChangedTo(x => x.IsArchived, true) catches it.
  • Entity and Original — the row as committed and the row as it was stored (null for 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 test Kind); 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(), at Commit(); for a TransactionScope, 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 calls SaveChanges() 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 a CanReact that ends the chain is part of the design.
  • Every SaveChanges(), through IEntityService or your own code on the DbContext, sync and async, as long as the context comes from DI (one you new up yourself is not wired). options.UseDefaults() wires it; without that, add DbContextWiring.Reactors to options.WireDbContext(...). What the interceptor never sees is ExecuteUpdate / 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.

Built with

Regira.Entities

Preppers, primers and the declarations that keep a PUT from blanking a field are part of Regira Entities.

What Regira Entities covers Entities documentation Free tier & licensing