Which Check Goes Where
Request, entity or database: placing each rule where it sees the right value
A rule about your data can be checked in three places: on the request as it arrives, on the record about to be stored, and in the database. Most rules would fit in any of them, and that is the trap. Each place sees something the others do not, and a rule in the wrong one fails in one of three ways:
- It is skipped. A check on the request never runs for an import or a nightly job, which send no request, nor for a delete, which carries no body.
- It is judged on the wrong value. A check that runs before the application tidies the input refuses
" acme-be ", which would have been stored asACME-BE. One that runs before the server fills in the owner or a timestamp sees them empty. - It is answered with the wrong message. The database holds its rules even when two saves race, but answers with a generic conflict that points at no field.
The way out is to put each rule where it sees what it is about: the request's shape at the door, the business rules on the record just before it is stored, the guarantees in the database. Those three layers exist in any ASP.NET Core application; Regira Entities fixes the order of the stages between them for you, and which of them a delete, a PATCH or a background job meets. This post follows one write through them, stage by stage, and ends with a table of which layer takes which rule.
The examples run in the sample of Validation in One Place, which introduces validators (download; dotnet run with the .NET 10 SDK, Regira.Entities.Web and Regira.Entities.Mapping.Mapster 6.6.0, SQLite; URL=http://localhost:5350, curl in bash).
One write, in order
A POST or PUT through a generated controller passes these stages:
- Model binding reads the JSON into the input DTO, and ASP.NET checks its annotations (
[Required],[MaxLength]). A failure answers 400, and the action never runs. - Mapping turns the DTO into a fresh entity and hands it to the entity service.
- Preppers tidy and complete the entity, with the stored row loaded on an update: trim, upper-case, derive a value, restore a server-owned field. A
Related()sync of a child list is a prepper too (Omitted Is Not Empty). - Validators judge the entity as the preppers left it, with the stored row on an update. Any error refuses the write: one 400 with every reason.
- Tracking. The entity is handed to EF Core; only now is it marked to be written.
SaveChanges. Primers stamp timestamps, a tenant or a soft delete, normalizers fill derived columns (Save Consistently from Every Screen and Job), andUseDefaults()cuts every string to its[MaxLength].- The database checks its constraints: unique indexes, foreign keys, not-null columns.
- Reactors run once the transaction has committed (Send the Mail Only When the Order Is Really Saved).
Other writes join later. A DELETE has no body, so stages 1 to 3 do not exist: the validators run first, on the row as stored, and only then is the delete marked. A PATCH merges the request into the stored row, checks the merged DTO's annotations, and goes on from stage 2. A job or import that sends a SaveCommand through IEntitySender gets the endpoint's checks, the input DTO's annotations included (An Import That Obeys the Same Rules as the Form); one that calls IEntityService starts at stage 3; one that writes on the raw DbContext starts at stage 6.
| Write | DTO annotations | Preppers | Validators | Primers, truncation | Database |
|---|---|---|---|---|---|
POST, PUT |
yes | yes | yes | yes | yes |
PATCH |
on the merged input | yes | yes | yes | yes |
DELETE |
— | — | on the stored row | yes | yes |
Job or import through IEntitySender (SaveCommand) |
yes | yes | yes | yes | yes |
Job or import through IEntityService |
— | yes | yes | yes | yes |
Raw DbContext |
— | — | — | yes | yes |
Why the validators run after the preppers
A validator answers "may this be stored?", so it must see what would be stored: the code trimmed, the total summed, the child list synced, the server-owned field restored. Before the preppers, it would judge what the client typed. In the sample, a prepper tidies a customer's code and a validator checks its format:
e.Prepare(c => c.Code = c.Code?.Trim().ToUpperInvariant());
// the format is judged on the prepper's value: " acme-be " arrives here as "ACME-BE"
e.Validate(ctx =>
{
if (ctx.Operation == EntityWriteOperation.Remove || ctx.Errors.Any(x => x.Key == nameof(Customer.Code)))
return;
if (!Regex.IsMatch(ctx.Item.Code!, "^[A-Z0-9-]{2,16}$"))
ctx.AddError(nameof(Customer.Code), $"{ctx.Item.Code} is not a valid code: 2 to 16 letters, digits or hyphens.");
});
curl -s -X POST $URL/customers -H "Content-Type: application/json" -d '{"code":" acme-be ","name":"Acme Belgium"}'
curl -s -X POST $URL/customers -H "Content-Type: application/json" -d '{"code":"acme be","name":"Acme Belgium"}'
{"duration":8,"isNew":true,"affected":1,"item":{"id":4,"code":"ACME-BE","name":"Acme Belgium"}}
"errors":{"Code":["ACME BE is not a valid code: 2 to 16 letters, digits or hyphens."]}
The second message quotes the value the rule judged. Move the rule onto the input DTO as [RegularExpression("^[A-Z0-9-]{2,16}$")] and the first request is refused too ("The field Code must match the regular expression '^[A-Z0-9-]{2,16}$'."), for a value the application would have stored without complaint. The uniqueness check in Validation in One Place relies on the same order: " acme " matches no stored code, ACME does.
Why they run before tracking and the primers
A refusal leaves nothing behind. The entity is not tracked yet, and what a Related() sync marked for this write is taken back, so a job that catches the refusal and moves on does not save half of the refused record with the next one. The same order is why a sequential code that a refused create must not use up is minted in a primer: minted in a prepper, or with e.ServerOwned(x => x.Code, mint), a create the validators refuse has already used its number. A rollback can still skip one; numbers that must have no gaps, such as invoice numbers, need a counter table.
What a primer stamps is not there yet. On an insert, a validator sees Created empty and TenantId as the client sent it; the primers overwrite both inside SaveChanges. A rule about those values reads their source instead: a per-tenant uniqueness check takes the signed-in tenant from ITenantContext, a payload naming another tenant is refused by comparing the two (One Database, Many Customers, No Leaks has the primer), and "no edits after 30 days" reads the stored ctx.Original.Created. A rule that must also hold for writes on the raw DbContext is no validator at all, but a primer or a constraint.
Rules about the request, rules about the entity
ASP.NET Core already validates the input DTO, and both layers answer with a 400 and the same errors map. The sample's CustomerDto carries [Required, MaxLength(128)] on Name, which no prepper touches. A request breaking a rule in each layer, then the same request with the name fixed:
curl -s -X POST $URL/customers -H "Content-Type: application/json" -d '{"code":"","name":""}'
curl -s -X POST $URL/customers -H "Content-Type: application/json" -d '{"code":"","name":"Acme Two"}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Name":["The Name field is required."]},"traceId":"00-932bee036ba38b8465f02d73fad5bb4d-11ef2590534d22d8-00"}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Code":["A code is required."]},"traceId":"00-d42bdbfa674f08082c3ad5bee7e04337-8ec4abddacde864e-00","errorDetails":[{"key":"Code","message":"A code is required."}]}
- Who answers. The first 400 is ASP.NET's: the action never ran, so nobody looked at the empty code, and the user hears about it only after fixing the name. The second comes from the entity pipeline and adds
errorDetails, each error with the arguments a translation fills in. A client that readserrors, astoFeedbackErrorin@regira/modulesdoes, shows both under their fields. - What each sees. An annotation sees the request as typed. A validator also sees the stored row, other rows, the operation and what the preppers made of the input.
- Which writes run it. Annotations run for
POST,PUTandPATCH, which checks the merged DTO (PATCH /customers/1with{"name":null}answers 400 forName, witherrorDetails, like the second 400 above). ADELETEhas no DTO, and neither does a job that callsIEntityServicedirectly: a customer added from code with an empty name is saved, once the validators have passed its code. Validators run on all of them.
One trap with FluentValidation: an AbstractValidator<CustomerDto> registered through UseFluentValidation(...) never runs, and nothing warns. The adapter runs a validator when a write service saves an entity of its type, and nothing saves a CustomerDto; give it and an AbstractValidator<Customer> the same rule, and only the entity's answers. Check the DTO with ASP.NET's own validation (annotations, or IValidatableObject across fields), and child rows from their parent, as Validation in One Place shows.
What the database is for
On purpose, nothing in the sample checks a product code's length or uniqueness. The entity says [MaxLength(16)], and the database has a unique index:
curl -s -X POST $URL/products -H "Content-Type: application/json" \
-d '{"code":"DRILL-BITS-HSS-10MM","name":"HSS drill bits, 10 mm","price":8.9}'
curl -s -X POST $URL/products -H "Content-Type: application/json" \
-d '{"code":"DRILL-BITS-HSS-12MM","name":"HSS drill bits, 12 mm","price":9.9}'
{"duration":15,"isNew":true,"affected":1,"item":{"id":3,"code":"DRILL-BITS-HSS-1","name":"HSS drill bits, 10 mm","price":8.9}}
{"title":"Conflict","status":409,"detail":"A database constraint rejected the change."}
A [MaxLength] on an entity refuses nothing: UseDefaults() registers an interceptor that cuts the string inside SaveChanges and logs Truncating Product.Code from 19 to 16 characters. The second code is cut the same way, collides with the first, and the index answers with a conflict that names no field. The data stays valid, but the user learns nothing to fix. With [MaxLength(16)] on ProductDto.Code, the first request becomes a 400 that names the field: "The field Code must be a string or array type with a maximum length of '16'."
The index stays when a validator checks uniqueness too. The query and the insert are two statements, so two requests can both pass the check, and only the index stops the second. A foreign key works the same way: the "customer with orders cannot be deleted" validator gives the reason, the Restrict key the guarantee on every path.
Which layer for which rule
| Rule | Layer | Why there |
|---|---|---|
| Required field | DTO [Required] |
the request's shape, answered first; also a validator if jobs must obey it |
| Maximum length | DTO [MaxLength], as the column's |
the entity's [MaxLength] truncates; a validator if a prepper changes the value |
| Format of a value a prepper tidies | validator | judges the result, not the raw input |
| Across fields of the request ("end after start") | DTO IValidatableObject |
needs only the request; a validator if jobs must obey it |
| State transition ("shipped stays shipped") | validator | needs ctx.Original, the stored row |
| Unique code, with a message | validator | queries the normalized code, excluding the row itself |
| Referenced row exists | validator through the read service | applies row security; keep the foreign key |
| No delete while referenced | validator on Remove, plus a Restrict key |
a reason instead of a 409; the key holds every path |
| Payload names another tenant | validator scoped to IHasTenantId |
compares with the signed-in tenant; the primer only overwrites |
| Who may write at all (a role) | an authorization filter | runs before model binding: an unauthorized write gets its 401 or 403 whatever its body holds, never a 400 listing its fields |
| Unique even when two saves race | unique index | only the database sees both inserts |
Why not validate everything in one place? Because no single place sees everything. The request layer sees the input untidied, and never sees a delete or a job. The validator stage sees the entity as it will be stored, but only for writes through the entity service. The database sees every write, even one that raced past a check, but answers one violation at a time and points at no field. The validators come closest, which is why Validation in One Place keeps the business rules there; the request's shape is still cheaper to check at the door, and the guarantees still belong in the schema.
Docs: Regira Entities — entity validators, preppers, the 400 body; ASP.NET Core model validation. The sample runs on the free tier, without a license key.
Versions: validators arrived in the Regira.Entities 6.5.0 packages, IEntitySender in 6.6.0; before 6.6.0 a PATCH's 400 came from MVC's validators, without errorDetails. The sample pins Regira.Entities.Web and Regira.Entities.Mapping.Mapster 6.6.0.