Save Consistently from Every Screen and Job
Clean input, stamped rows and derived values in one place
Every application has rules about what is saved. A ticket remembers when it was closed. A new order gets a number, and nobody changes it afterwards. Names are stored without stray spaces, and every record remembers when it was last changed. None of these rules is hard to write. The hard part is making them hold on every save — through the screen, an import or a nightly job — and not only on the path someone tested. A rule written where it was first needed is forgotten by the next thing that saves the same data, and the data quietly stops meaning what the business thinks it means.
The fix is to give each rule a home by what it protects:
- Rules about the request — tidy the input, fill in what the server decides — run before anything is saved, for changes that come in through the application.
- Rules about the stored data — timestamps, a stamp when the status changes, archiving instead of deleting — run on every save, whoever makes it, so an import or a background job cannot skip them.
- Values derived from other values, such as a search column built from a name, are declared once and recomputed on every save.
These rules change what is stored. The rules that only say no — a required field, a closed ticket that stays closed — have a home of their own, the next part.
The usual homes fall short: left in the controller, a rule is duplicated by the second endpoint that writes the same entity; left in one DbContext override, every rule for every entity meets in one method. Regira Entities gives each kind its own hook along the path an entity already travels from the request to the row. The rest of this post follows one save along that path, on a small ticketing API you can download and run: dotnet run with the .NET 10 SDK, Regira.Entities.Web and Regira.Entities.Mapping.Mapster from nuget.org, SQLite, no setup; the curl examples are bash (Git Bash on Windows). This opens Writing in Regira Entities, the write-side mirror of the filtering series; its third part builds on the same sample.
The journey of one PUT
Here is what happens when a client sends PUT /tickets/1 to the sample.
- The generated controller maps
TicketInputDtoto a freshTicketinstance and callsIEntityService<Ticket>.Save(item)(Regira.Entities.Services.Abstractions). - The write service loads the stored row (
original) and runs every registered prepper with both:Prepare(modified, original). - The validators judge what the preppers left, and refuse the write here if a rule is broken: the next part.
- The entity is attached to EF Core with its original values copied from the stored row, and marked
Modified. SaveChangesruns. TwoSaveChangesInterceptors fire on the pending entries: the primers first, then the normalizers.- The rows are written.
The three hooks this post is about change the entity; what distinguishes them is what they can see and which writes they cover.
| Hook | Runs | Sees | Covers |
|---|---|---|---|
| Prepper | in the write service, before EF | the incoming entity and the stored original | writes through IEntityService only |
| Primer | inside SaveChanges |
the EntityEntry — state, original values, current values |
every save on the context, whoever called it |
| Normalizer | inside SaveChanges |
the entity | every save on the context |
The table comes down to one question — does the rule need to hold for writes that bypass the API? A rule about the request (cleaning it, completing it, a value the server decides) is a prepper; a rule about the row (timestamps, transitions, soft delete, anything a seeder or a job must also respect) is a primer; a derived column is a normalizer, and nine times out of ten an attribute.
Preppers: the request-side rules
A prepper changes the incoming entity before EF Core sees it. The sample's is one line, registered with the entity:
e.Prepare(t => t.Subject = t.Subject?.Trim());
Run the sample and try it:
URL=http://localhost:5310 # the sample's port, from launchSettings.json
curl -X POST "$URL/tickets" -H "Content-Type: application/json" \
-d '{"subject":" Badge reader at the side door ","status":"Open"}'
# -> "subject":"Badge reader at the side door"
A prepper that needs more is a class, EntityPrepperBase<Ticket> (Regira.Entities.Preppers.Abstractions), registered with e.AddPrepper<T>(). Its Prepare(modified, original) gets the stored row on an update and null on a create, so it can complete the entity from what was there; e.Prepare(async (Ticket t, AppDbContext db) => …) is the form for a value that needs a query.
A prepper tidies and completes; it does not refuse. The sample's three refusals — an empty subject, a reopened ticket, two open tickets with one subject — are validators, which run after every prepper and on a delete too, and answer with every broken rule at once: the next part.
The limit of a prepper is the flip side of its strength: it runs in the entity service, so a background job that writes through the raw DbContext never meets it. That is by design, and it is the reason the next hook exists.
Primers: the rules every save obeys
A primer runs inside SaveChanges, for every pending entry of its type, no matter what code queued it. It gets EF's EntityEntry, so it can read the entry's state and its original values:
public class TicketClosedOnPrimer : EntityPrimerBase<Ticket>
{
public override Task PrepareAsync(Ticket entity, EntityEntry entry, CancellationToken token = default)
{
if (entry.State is not (EntityState.Added or EntityState.Modified))
return Task.CompletedTask;
var wasClosed = entry.State == EntityState.Modified
&& entry.OriginalValues[nameof(Ticket.Status)] is TicketStatus.Closed;
if (entity.Status == TicketStatus.Closed && !wasClosed)
entity.ClosedOn = DateTime.UtcNow;
return Task.CompletedTask;
}
}
// registration: e.AddPrimer<TicketClosedOnPrimer>();
"Stamp ClosedOn on the transition to Closed" is a primer's kind of rule: it is about the row's history, not the request, and it must hold whether the status changed through PUT /tickets/1, a seeder or a nightly job. The built-ins UseDefaults() registers are all of this kind — HasCreatedDbPrimer and HasLastModifiedDbPrimer for the timestamps, and ArchivablePrimer, which turns DELETE into an update (Delete Without Losing Anything). Implementing the marker interfaces is all it takes; the sample's Ticket : IHasTimestamps, IArchivable never mentions them again.
Primers run in registration order, and they see deleted entries too, which is why the one above checks entry.State first. ArchivablePrimer comes first: it turns the delete of an IArchivable into an update of the IsArchived flag, so the primers after it see an update, and the LastModified they stamp is written with the flag. On a hard delete, nothing a primer sets is written.
Normalizers: derived columns
The third hook computes values from the entity rather than deciding anything about it. Most of the time you never write one, because an attribute does the job:
public class Ticket : IEntityWithSerial, IHasTimestamps, IHasNormalizedContent, IArchivable
{
// ...
[MaxLength(512), Normalized(SourceProperties = [nameof(Subject), nameof(Description)])]
public string? NormalizedContent { get; set; }
}
UseDefaults() fills the column from its sources on every save, and the built-in ?q= filter reads it — how and why is A Search Box That Finds Café When You Type Cafe. When the derived value needs data the attribute cannot see, an EntityNormalizerBase<T> registered with e.AddNormalizer<T>() runs after the default one.
Everything in one place
The sample's write contract for tickets, in one registration — there is nowhere else for a rule to hide:
public static EntityServiceCollection<AppDbContext> AddTickets(this IEntityServiceCollection<AppDbContext> services)
=> services.For<Ticket>(e =>
{
e.Prepare(t => t.Subject = t.Subject?.Trim()); // request-side: tidy
e.ServerOwned(x => x.Code, _ => $"TCK-{Guid.NewGuid().ToString("N")[..6].ToUpperInvariant()}");
e.AddValidator<TicketValidator>(); // refuse: the next part
e.Validate(async (ctx, db, token) => { /* no two open tickets with one subject */ });
e.AddPrimer<TicketClosedOnPrimer>(); // every save: stamp the transition
// normalizing: the [Normalized] attribute + UseDefaults() cover it
});
The ServerOwned line is a prepper too, the one the third part is about.
The rules you will actually write
The rules a first project meets, where each one goes, and the shortest correct form — every row is in the sample:
| Rule | Hook | Shape |
|---|---|---|
| Trim, upper-case a code, strip formatting | prepper | e.Prepare(t => t.Code = t.Code?.Trim().ToUpperInvariant()) |
| Refuse: a required field, "closed stays closed", a unique code → 400 | validator | e.Validate(ctx => …), the next part |
Field the client sees but never sets (Code, ClosedOn) |
server-owned (prepper) | [ServerOwned]; e.ServerOwned(x => x.Code, mint) when it must also be minted |
Created / LastModified, soft delete |
built-in primers | implement IHasTimestamps, IArchivable |
Stamp on a transition (ClosedOn, PublishedOn) |
primer | entry.OriginalValues[...] vs current |
| Search column from several text fields | normalizer | [Normalized(SourceProperties = […])] |
Alternatives
| Option | Fits when | What it costs |
|---|---|---|
By hand: override SaveChangesAsync on the DbContext, or register a SaveChangesInterceptor whose SavingChangesAsync loops over ChangeTracker.Entries<Ticket>(), branching on entry.State and reading entry.OriginalValues |
a few row rules on a few entities | a refusal there is an exception you map to a 400 yourself, and on an entity attached with Update(), OriginalValues holds what the client sent, so a transition check has to load the stored row first |
Methods on the entity (ticket.Close()) behind private setters |
code that loads the entity and changes it through those methods | a PUT that maps a DTO onto the entity has no method to call, and the stamps every entity shares, such as LastModified, still need the save |
| Database triggers, defaults and computed columns | other applications write the same tables | the rules leave the C# code for provider-specific SQL, and a trigger's refusal reaches the API as a DbUpdateException that names no field |
Docs: Regira Entities — services, preppers, primers, normalizing. The sample runs on the free tier, without a license key.
Versions: the sample pins Regira.Entities.Web and Regira.Entities.Mapping.Mapster 6.6.0. Validators arrived in 6.5.0, which also answers a refused write's 400 as a ProblemDetails, the field map under errors.