← All articles

Rules That Hold on Every Save

One place for validation, timestamps and computed fields

One place for each rule a save must obey, so it holds whether a user, an import or a nightly job did the saving. Required fields, timestamps and a code that never changes stop being scattered across controllers.
Rules That Hold on Every Save

Every application has rules about what may be saved. A closed ticket cannot be reopened. 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 — is this input acceptable, tidy it up, answer with a clear error — run before anything is saved, for changes that come in through the application.
  • Rules about the stored data — timestamps, allowed 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.

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; the next post builds on the same sample.

Universal or Regira? Placing a rule by its scope is the pattern — request-side rules before the ORM sees the entity, row-side rules inside SaveChanges, where EF Core's own SaveChangesInterceptor already puts code in front of every write on the context. Regira Entities adds the three hook base classes, their registration next to the entity, the built-ins UseDefaults() brings, and the 400 a rejected write turns into.

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, attaches the entity 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.

  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 entity is attached to EF Core with its original values copied from the stored row, and marked Modified.
  4. SaveChanges runs. Two SaveChangesInterceptors fire on the pending entries: the primers first, then the normalizers.
  5. The rows are written.

All three hooks mutate 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 (validation, cleaning, anything that should produce a 400) 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 is one class with one method, registered for one entity type:

using Regira.Entities.Models;                 // EntityInputException (package Regira.Entities)
using Regira.Entities.Preppers.Abstractions;  // EntityPrepperBase<T>

public class TicketValidationPrepper : EntityPrepperBase<Ticket>
{
    public override Task Prepare(Ticket modified, Ticket? original, CancellationToken token = default)
    {
        modified.Subject = modified.Subject?.Trim();

        var errors = new Dictionary<string, string>();
        if (string.IsNullOrWhiteSpace(modified.Subject))
            errors["subject"] = "A subject is required.";
        if (original?.Status == TicketStatus.Closed && modified.Status != TicketStatus.Closed)
            errors["status"] = "A closed ticket cannot be reopened.";

        if (errors.Count > 0)
            throw new EntityInputException<Ticket>("Ticket rejected") { Item = modified, InputErrors = errors };

        return Task.CompletedTask;
    }
}
// registration: e.AddPrepper<TicketValidationPrepper>();

Two things make this the right place:

  • original is null on create and the stored row on update, so "closed tickets stay closed" has the information without a query.
  • A prepper can reject the write. EntityInputException is understood by the controller layer, which returns the field-error map as a 400.

Run the sample and try it:

URL=http://localhost:5310                 # the sample's port, from launchSettings.json
curl -i -X PUT "$URL/tickets/1" -H "Content-Type: application/json" \
     -d '{"id":1,"subject":"   ","status":"Open"}'
# -> 400 {"subject":["A subject is required."]}

Most validation needs no class at all: e.Prepare(t => t.Subject = t.Subject?.Trim()) for a one-liner, and e.Prepare(async (Ticket t, AppDbContext db) => …) for a rule that needs a query — the sample's "no two open tickets with the same subject", the kind of rule that usually ends up in the controller because it needs the database.

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.AddPrepper<TicketValidationPrepper>();                 // request-side: validate, clean, reject
        e.ServerOwned(x => x.Code, _ => $"TCK-{Guid.NewGuid().ToString("N")[..6].ToUpperInvariant()}");
        e.AddPrimer<TicketClosedOnPrimer>();                     // every save: stamp the transition
        // normalizing: the [Normalized] attribute + UseDefaults() cover it
    });

The ServerOwned line is a prepper too — the fluent form of a 6.2 feature the next post 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())
Required field, cross-field check → 400 prepper throw EntityInputException<T> with InputErrors
Unique e-mail / code among live rows prepper with DbContext e.Prepare(async (t, db) => …)
Status transition guard ("closed stays closed") prepper class form, compare original.Status
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 = […])]

Why not one SaveChanges override? It is one concept where this post has three, and that is its appeal. What the hooks buy is that each rule lives next to the entity it governs, runs in the stage that matches its scope, and can be unit-tested with a new Ticket() and no database. Put a breakpoint in each of the sample's three classes, send one PUT, and watch them fire in order.

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.4.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