← All articles

Validation in One Place

The Rules That Say No

Business rules that refuse a change live in one place, hold for every screen and import, and cover deleting as well as saving. A user who breaks three rules hears about all three at once, next to the fields they concern.
Validation in One Place

An order cannot ship without a delivery address. A customer who still has orders cannot be deleted. Two customers cannot share a code. Every business application has rules like these, and they have one thing in common: they do not fix anything, they say no.

In most codebases they end up wherever someone first needed them. One is an if in a controller action, another an exception thrown from a service, a third is left to a database constraint that answers with a generic error. The second screen that saves the same data checks a little differently, the delete path checks nothing, and a user who broke three rules learns about them one save at a time.

The fix is one place for those rules, consulted on every save and every delete that goes through the application, collecting every objection before it answers: one refusal, with every reason, each next to the field it concerns. Regira Entities has that place built into its write path: the validator.

Validators join the hooks of Save Consistently from Every Screen and Job: preppers tidy and fill the entity, primers stamp the row inside SaveChanges, and validators refuse. A prepper can throw a 400 too, but the first one that throws ends the save before the others have their say, and no prepper runs on a delete. Which Check Goes Where explains why they sit exactly there, and which rules belong elsewhere.

Everything below runs in the sample: dotnet run with the .NET 10 SDK, Regira.Entities.Web and Regira.Entities.Mapping.Mapster 6.6.0, SQLite created and seeded on first start; the curl examples are bash.

The first validator

A validator is registered next to the entity, like every other rule:

using Regira.Entities.Validators.Abstractions;   // EntityWriteOperation, IEntityValidatorContext<T>

services.For<Order>(e =>
{
    e.Validate(ctx =>
    {
        var order = ctx.Item;
        if (ctx.Operation == EntityWriteOperation.Remove)
        {
            if (order.Status >= OrderStatus.Shipped)
                ctx.AddError(nameof(Order.Status), $"A {order.Status.ToString().ToLowerInvariant()} order cannot be deleted.");
            return;
        }

        if (order.Status >= OrderStatus.Shipped && string.IsNullOrWhiteSpace(order.DeliveryAddress))
            ctx.AddError(nameof(Order.DeliveryAddress), "An order cannot ship without a delivery address.");
    });
});

The context carries everything a rule needs:

  • Item — the entity about to be written, as the preppers left it.
  • Original — the row as stored, on an update; null on an insert and a delete.
  • Operation — Add, Modify or Remove. A soft delete of an archivable entity is a Remove as well.
  • AddError(key, message) — the key is the property path, or "" for the entity as a whole.

A validator reads and never writes: changing the entity is a prepper's job.

Every reason in one answer

The second rule looks another entity up through a service, so it is a class, constructor-injected; why a service and not the DbContext comes further down:

using Regira.Entities.Services.Abstractions;     // IEntityReadService<TEntity, TKey>

public class OrderCustomerValidator(IEntityReadService<Customer, int> customers) : EntityValidatorBase<Order>
{
    public override async Task Validate(IEntityValidatorContext<Order> ctx, CancellationToken token = default)
    {
        if (ctx.Operation == EntityWriteOperation.Remove)
            return;

        if (await customers.Details(ctx.Item.CustomerId, token) is null)
            ctx.AddError(nameof(Order.CustomerId), $"Customer {ctx.Item.CustomerId} does not exist.");
    }
}
// registration, inside For<Order>(): e.AddValidator<OrderCustomerValidator>();

Every validator in scope runs and adds to one list. When the list is not empty, the write service throws one EntityInputException holding all of it, before the entity is tracked, and the controller answers with a 400:

URL=http://localhost:5350

curl -s -i -X POST $URL/orders -H "Content-Type: application/json" \
  -d '{"customerId":99,"status":"Shipped"}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"CustomerId":["Customer 99 does not exist."],"DeliveryAddress":["An order cannot ship without a delivery address."]},"traceId":"00-e359f4f82576f9e7a6042fe1c44ee1c3-6b2a8a0c92a072d3-00","errorDetails":[{"key":"DeliveryAddress","message":"An order cannot ship without a delivery address."},{"key":"CustomerId","message":"Customer 99 does not exist."}]}

Two rules, two validators, one answer. From here on, only the body's errors is shown.

A delete is judged by the row as stored

A DELETE carries a key and nothing else. A rule like "a shipped order cannot be deleted" needs the order's status, so the write service loads the stored row and hands that to the validators:

curl -s -X DELETE $URL/orders/3      # order 3 is Shipped
"errors":{"Status":["A shipped order cannot be deleted."]}

The same holds for your own code: Remove(new Order { Id = 3 }) is judged by the stored status, not by the empty instance it was given. A rule about the entity as a whole uses the empty key, as the sample's "a customer with orders cannot be deleted" does — "errors":{"":["Bakkerij De Korenaar has 1 order(s) and cannot be deleted."]} — the 400 the client reads instead of the foreign key's 409.

One rule for every entity with a code

Some rules belong to a capability rather than to an entity. A validator is scoped by its type argument, and that argument can be an interface:

using Regira.Entities.Models.Abstractions;       // IHasCode

public class CodeValidator : EntityValidatorBase<IHasCode>
{
    public override Task Validate(IEntityValidatorContext<IHasCode> ctx, CancellationToken token = default)
    {
        if (ctx.Operation == EntityWriteOperation.Remove)
            return Task.CompletedTask;

        if (string.IsNullOrWhiteSpace(ctx.Item.Code))
            ctx.AddError(nameof(IHasCode.Code), "A code is required.");
        else if (ctx.Original is { } stored && stored.Code != ctx.Item.Code)
            ctx.AddError(nameof(IHasCode.Code), $"The code cannot change once it is set (it is {stored.Code}).");

        return Task.CompletedTask;
    }
}

// in UseEntities(): once, for every entity
options.AddValidator<CodeValidator>();   // using Regira.Entities.DependencyInjection.Validators;

Customers and products both implement IHasCode, and neither registration mentions the rule:

curl -s -X PUT $URL/products/1 -H "Content-Type: application/json" \
  -d '{"id":1,"code":"BOLT-M10","name":"Bolts M8, box of 100","price":12.5}'
curl -s -X PUT $URL/customers/1 -H "Content-Type: application/json" \
  -d '{"id":1,"code":"ACME-BE","name":"Acme Tools"}'
"errors":{"Code":["The code cannot change once it is set (it is BOLT-M8)."]}
"errors":{"Code":["The code cannot change once it is set (it is ACME)."]}

Two things about scope. The item's runtime type decides, so a validator on IHasTenantId checks every tenant-owned entity, and one on a base class checks each subclass, whichever service saves it. And the place of registration never narrows it: an interface-scoped validator added inside For<Order>() still checks every entity with a code, so register those once, in UseEntities().

To keep the stored code silently instead of refusing, [ServerOwned] restores it (An Update That Blanks a Field the Form Never Showed); a validator is for when the client should hear about it.

Checks that need the database

Uniqueness needs a query. The third form of Validate receives the request's DbContext and the write's cancellation token:

using Microsoft.EntityFrameworkCore;              // AnyAsync

services.For<Customer>(e =>
{
    // a prepper tidies the value; the validators judge what it left
    e.Prepare(c => c.Code = c.Code?.Trim().ToUpperInvariant());

    e.Validate(async (ctx, db, token) =>
    {
        // CodeValidator, registered globally, ran first: no query when the code already failed
        if (ctx.Operation == EntityWriteOperation.Remove || ctx.Errors.Any(x => x.Key == nameof(Customer.Code)))
            return;

        if (await db.Customers.AnyAsync(c => c.Code == ctx.Item.Code && c.Id != ctx.Item.Id, token))
            ctx.AddError(nameof(Customer.Code), $"Code {ctx.Item.Code} is already in use.");
    });
});
curl -s -X POST $URL/customers -H "Content-Type: application/json" \
  -d '{"code":" acme ","name":"Acme Two"}'
"errors":{"Code":["Code ACME is already in use."]}

c.Id != ctx.Item.Id keeps a customer from colliding with itself on an update. And the unique index stays in the model: two saves racing each other can both pass the check, and the index is what stops the second.

That query goes straight to the DbContext, which is fine for a rule over the whole table. A reference check is different, which is why OrderCustomerValidator asks the customers' read service instead. Row security in Regira Entities — a tenant filter, an owner filter — is applied by the entity services' query pipeline, not by the DbContext. db.Customers.AnyAsync(...) sees every tenant's customers, so it would accept an order pointing at another tenant's customer, and its error would confirm to the client which ids exist. customers.Details(id) runs the filters and finds nothing. Inject the read service, never IEntityService<>: the write service imports every validator, so that would be a dependency cycle.

FluentValidation, if the team already writes it

Regira.Entities.Validation.FluentValidation runs AbstractValidator<T> classes in the same stage, under the same scope rule. It is commercially licensed with the same free tier as the other Entities packages.

// dotnet add package Regira.Entities.Validation.FluentValidation --version 6.6.0
using FluentValidation;
using Regira.Entities.Validation.FluentValidation;

public class OrderValidator : AbstractValidator<Order>
{
    public OrderValidator()
    {
        RuleFor(x => x.DeliveryAddress)
            .NotEmpty().When(x => x.Status >= OrderStatus.Shipped)
            .WithMessage("An order cannot ship without a delivery address.");

        // a delete runs this rule set alone, against the row as stored
        RuleSet(EntityRuleSets.Remove, () =>
            RuleFor(x => x.Status).Must(status => status < OrderStatus.Shipped)
                .WithMessage("A shipped order cannot be deleted."));
    }
}

// in UseEntities(): registers the assembly's validators and runs them in the validator stage
options.UseFluentValidation(typeof(OrderValidator).Assembly);

An insert or update runs the rules outside any rule set, plus EntityRuleSets.Add or EntityRuleSets.Modify; ctx.GetOriginal() reads the stored row from a three-argument Must. Only Severity.Error failures refuse the write. With this class in place of the sample's inline order rules, the two calls above answer with the same errors.

What the front end gets

The 400 body is a ValidationProblemDetails, the shape of ASP.NET's own model-validation 400: errors maps each key (whatever the validator passed, here nameof(...)) to its messages, and errorDetails lists every error in the order it was added, as { key, message, args }. In @regira/modules, toFeedbackError turns that into a field map with each key's first letter lower-cased. <Feedback> lists it, each field under its label; a form that shows the messages at its inputs reads them with fieldMessages(feedback.error, "deliveryAddress") and sets hide-field-errors (Saving, Saved or Failed shows the binding). A message can also be a translation key: ctx.AddError(nameof(Customer.Code), "CodeInUse", new { code = ctx.Item.Code }) sends "args":{"code":"ACME"} with it, and toFeedbackError shows the app's translation of CodeInUse, from its useLang messages or a translator set with setErrorTranslator, with {code} filled in, or the message as sent when there is none. A refused delete answers the same body, beside the 409 of a row that is still referenced; useForm shows it the same way, and its changeState ends in error, never in removed (Edit Forms That Survive Reality covers the form).

Where a validator does not reach

  • Writes through the raw DbContext. Validators run in the entity service, like preppers, so a seeder or a job that saves on the context directly skips them. A rule that must hold there too belongs in the database: a constraint, a unique index, a foreign key.
  • Children synced by Related(). Order lines saved with their order are checked by the order's validator, with keys like Lines[0].Quantity; a validator scoped to the line type never runs for them. Start-up validation warns when a Regira validator has no registered entity in scope, but never about an AbstractValidator<OrderLine>: check lines from the order's, with RuleForEach(x => x.Lines).SetValidator(...).

The rules you will actually write

Rule Form Shape
Required when ("shipping needs an address") e.Validate(ctx => …) test ctx.Item, add an error per broken field
Status transition ("closed stays closed") e.Validate(ctx => …) compare ctx.Original.Status with ctx.Item.Status; Original is set on every update
No delete in some state Operation == Remove ctx.Item is the stored row
No delete while referenced async with db, on Remove count the references, error under ""; keep the foreign key
Unique code (per tenant) async with db exclude ctx.Item.Id; add the tenant from ITenantContext to the predicate, since the primer stamps the row's tenant only at SaveChanges; keep the unique index
The referenced row exists class with IEntityReadService<,> Details(id) is null → error; the read service applies row security
A payload naming another tenant EntityValidatorBase<IHasTenantId>, in UseEntities() ctx.Item.TenantId set and not the signed-in tenant → error
An upload to a row the caller cannot read class on the attachment link with the owner's IEntityReadService<,>, on Add Details(ctx.Item.ObjectId) is null → error; the upload is a create no filter sees, and the link's reads need a filter of their own (One Database, Many Customers, No Leaks)
Allowed file types on attachments the attachment link's validator and the owner's an owner's PUT that syncs its Attachments runs only the owner's validators, so repeat the rule there (keys like Attachments[0].NewFileName)

Alternatives

Option Fits when What it costs
By hand: a Check(order, stored) method per entity that collects into a Dictionary<string, string[]> and answers Results.ValidationProblem(errors) when it is not empty; every write path, the import and the delete included, calls it first a few entities, and one service layer every write passes through every path has to remember the call and load the stored row first; an if in one controller action is the same check for that endpoint only
Data annotations or IValidatableObject on the input DTO the request's shape: a required field, a maximum length, "end after start" ASP.NET answers them before the action runs, but they see only the request: not the stored row, the database or a delete
FluentValidation, called from each endpoint the team already writes AbstractValidator<T> classes the classes collect every error, but each path still has to call them and hand them the stored row; the adapter above runs the same classes in the validator stage
A database constraint: a unique index, a foreign key a guarantee that must hold on every path, races included one violation at a time, with a message written for a log; keep it beside the validator, as the sample does

Docs: Regira Entities — entity validators, and FluentValidation. The sample runs on the free tier, without a license key.

Versions: validators and the FluentValidation adapter arrived in the Regira.Entities 6.5.0 packages; @regira/modules 6.5.0 added fieldMessages, field labels and hideFieldErrors in the summary, message-key translation in toFeedbackError, and a refused delete ending useForm in error. 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