← All articles

Save Consistently from Every Screen and Job

Clean input, stamped rows and derived values in one place

One place for each rule a save must obey, so it holds whether a user, an import or a nightly job did the saving. Tidied input, timestamps and a code that never changes stop being scattered across controllers.
Save Consistently from Every Screen and Job

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.

One PUT through the write path: the controller maps the DTO to a fresh Ticket, the write service loads the stored row and runs the prepper with both, the validators let it pass or refuse it, the entity is attached as Modified, and SaveChanges runs the primers and then the normalizers before the rows are written. A seeder or nightly job writing through the raw DbContext enters at SaveChanges and never meets a prepper or a validator.

  1. The generated controller maps TicketInputDto to a fresh Ticket instance and calls IEntityService<Ticket>.Save(item) (Regira.Entities.Services.Abstractions).
  2. The write service loads the stored row (original) and runs every registered prepper with both: Prepare(modified, original).
  3. The validators judge what the preppers left, and refuse the write here if a rule is broken: the next part.
  4. The entity is attached to EF Core with its original values copied from the stored row, and marked Modified.
  5. SaveChanges runs. Two SaveChangesInterceptors fire on the pending entries: the primers first, then the normalizers.
  6. 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.

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