A generic, extensible framework for managing data entities in .NET with standardized CRUD operations, filtering, sorting, and includes.
| Project | Package | Purpose |
|---|---|---|
Common.Entities |
Regira.Entities |
Shared abstractions and interfaces |
Entities.EFcore |
Regira.Entities.EFcore |
EF Core EntityRepository |
Entities.Web |
Regira.Entities.Web |
ASP.NET Core Endpoints |
Entities.DependencyInjection |
Regira.Entities.DependencyInjection |
UseEntities() / .For<>() DI builder |
Entities.Mapping.Mapster |
Regira.Entities.Mapping.Mapster |
Mapster integration |
Entities.Mapping.AutoMapper |
Regira.Entities.Mapping.AutoMapper |
AutoMapper integration (deprecated) |
Entities.Validation.FluentValidation |
Regira.Entities.Validation.FluentValidation |
FluentValidation rules in the write pipeline (§Step 8 → Validators) |
Always prefer clear, conventional patterns over clever solutions. Default to the more feature-rich options when in doubt. Use the latest .NET version (net10) unless instructed otherwise.
Regira.Entities.DependencyInjection validates a license key at startup (product code regira.entities). The free tier needs no call — omit UseRegira() entirely. To apply paid keys, register them once via UseRegira() before calling UseEntities():
// Program.cs — before UseEntities
services.UseRegira(configuration); // paid keys only — omit this line on the free tier
services.UseEntities<AppDbContext>(options =>
{
// no license key here — resolved from the registered License
options.UseDefaults();
});
Without a key, the free tier applies automatically — no configuration for dev/CI: 5 simple + 2 complex registrations, two independent buckets (counted separately, not a shared pool of 7), one slot per distinct entity type, and one extra simple slot per attachment owner (the per-owner join entity via HasAttachments; the shared Attachment base via WithAttachments is free infrastructure, however many owners use it). Exceeding either bucket throws at startup naming the offenders. Classify each entity and tally the budget before scaffolding — the decision table, the simple/complex definitions, and a worked example live in §Step 0, the canonical spot.
Store paid keys under Regira:LicenseKeys in appsettings.json. A single key can cover multiple products; add more keys to the array to combine them — the system picks the best per product (paid always wins over free). Obtain a key at https://regira.com/licensing.
Create project: → entities.setup.md
Add entity: → §Step 0, then §Entity Implementation Workflow
Modify entity: update the entity + DbSet/relationships, then propagate to whatever the change touches — DTOs/mapping, SearchObject/enums/query builders (filters & sorting), processors/preppers/normalizers (behavior).
Reading guide: read §Step 0 and §Entity Implementation Workflow before the first entity, then §Steps 1–5 / §Steps 6–10 / §Steps 11–15 as the happy path sends you into them. Everything from §Custom Entity Services on is a lookup table — fetch a section when you have the symptom, not up front. The one exception is §Security & Authorization: if any row belongs to a user or tenant, read it before you design entities — row scoping constrains DTOs, preppers and query builders, and retrofitting it is expensive. Exact signatures: get_type / entities.signatures.md, never guessed.
Something looks wrong at runtime — a short page, an empty search, a value that reset: → §Troubleshooting first. It is grouped by symptom, and each row names the section that owns the rule. Most of those failures build clean and answer 200.
Namespaces: entities.namespaces.md — never guess, invent, or assume a namespace.
Signatures: entities.signatures.md — never guess method names, parameter types, or return types; always verify here.
The moving parts: POCO entity (IEntity<TKey>) → IEntityService (default EntityRepository, DbContext-backed) → EntityControllerBase, with a read TDto / write TInputDto pair and seven pipeline extension points: QueryBuilders, Processors, Preppers, Validators, Primers, Reactors, AfterMappers.
| Type | Required | Purpose | Default (when omitted) | Example |
|---|---|---|---|---|
| TEntity | ✓ | The entity class | - | Product |
| TKey | ○ | Primary key type | int |
Guid, int |
| TSearchObject | ○ | Advanced filtering | SearchObject |
ProductSearchObject |
| TSortBy | ○ | Sorting enum | EntitySortBy |
ProductSortBy |
| TInclude | ○ | Navigation properties enum | EntityIncludes |
ProductIncludes |
| TDto | ○ | Read/display model (details & lists) | TEntity |
ProductDto |
| TInputDto | ○ | Create/update model | TEntity |
ProductInputDto |
EntitySortBy/EntityIncludesare the default types, not request-driven behavior on simple entities. A simple registration applies one fixedSortBy(q => q.OrderBy(...))order and ignores the request’s?sortBy=;?includes=is likewise not bound on simple List/Search. A gatedIncludes(...)navigation therefore loads only onDetails(which passesEntityIncludes.All) — on List/Search it stays off unless you eager-load it unconditionally. To let a client pick the sort key or opt into individual navigations per request, promote to the complexFor<…, TSortBy, TIncludes>()path (SortBy((q, key) => key switch { … })+ typed[Flags]includes) — only complex bases bind?sortBy=/?includes=.
Read Pipeline:
DbSet → QueryBuilders (Filters → Sorting → Paging → Includes) → Processors → Mapping → AfterMappers*
*AfterMappers run only in API controllers
Write Pipeline:
Input → Mapping* → AfterInput* → Preppers → Validators → SaveChanges → Primers (Interceptors) → Submit → Commit → Reactors
*Only executed in API controllers
SaveChanges() is explicit. Base controllers call it for you; direct IEntityService callers (seeding, jobs, custom services) must await service.SaveChanges() themselves.
Inline vs separate class. Inline for simple (<10 lines), entity-specific, non-reusable logic. Separate class when it needs DI (DbContext/services), is reused across entities, or warrants isolated testing.
Extend, don’t add endpoints. Reach for a SearchObject property before a custom controller action; add actions only when the base methods genuinely can’t express the operation.
Default EntityRepository vs a wrapping service. Stay on the default while the custom logic fits a QueryBuilder / Processor / Prepper / Validator / Primer / Reactor. Wrap (EntityWrappingServiceBase) when the behavior sits around the call rather than inside the pipeline — caching, auditing, combining data sources. Refusing a write is a validator’s job, cross-entity rules included (§Step 8 → Validators).
(One-time project bootstrap. Distinct from the per-entity Step N scheme below.)
Full procedure — packages, Program.cs, DbContext, DI extension method: entities.setup.md. Defaults unless instructed otherwise: net10, SQLite + Database.EnsureCreated() (no initial migration), Mapster, per-entity folders in a single project (when and how to split it by layer: §Project Structure), default EntityRepository.
The two rules that are easy to get wrong and that entities.setup.md assumes you already know:
IEntityServiceCollection<T>vsEntityServiceCollection<T>:UseEntities<TContext>()returns the concreteEntityServiceCollection<TContext>, which implementsIEntityServiceCollection<TContext>. Write your extension methods with the interface as thethisparameter (this IEntityServiceCollection<TContext> services) but declare the return type as the concreteEntityServiceCollection<TContext>— everyFor<>()overload already returns the concrete type, and only the concrete type implementsIServiceCollection. This means chains can be assigned to or returned asIServiceCollectionwithout any extra unwrapping call.
EntityServiceCollection<TContext>implementsIServiceCollection— it inherits fromServiceCollectionWrapper : IServiceCollection, so returning it from a method typedIServiceCollectioncompiles directly. You can also call.GetServices<TContext>()to explicitly unwrap toIServiceCollectionif needed, e.g. when the chain is broken across multiple statements.
The canonical classification/budget spot — the card and §License requirement point here. Three decisions per entity, then a budget tally. Do this for the whole domain before scaffolding anything.
Decision Pick Consequence Addressable or owned? independently addressable entity, or owned child (order lines, join / share / member rows) Owned child → e.Related()on the parent, no.For<>(), no controller, no budget slot; the SPA edits it inside the parent form. Give it its own.For<>()+ controller only when clients must address its rows outside the parent. Deciding late is the most expensive rework (§Relationship Patterns — Decision Table).Simple or complex? simple = For<>()withoutTSortBy/TIncludes; complex =For<…, TSortBy, TIncludes>()Sets the .For<>()overload, theEntityControllerBase<>generics (Step 13), and which endpoints exist. Complex is required for typed sorting/includes and the batchPOST /list&POST /search;GET /search(withcount) exists for simple too. An attachment owner’sHasAttachmentsjoin entity is classified simple.⚠️ Who writes it? one writer per save path A parent’s Related()sync and the child’s ownIEntityService<T>coexist only while the parent’s input DTO leaves that collectionnull(the sync then no-ops). Send it and the parent wins —[]deletes every row, and fields missing from the child input DTO reset to default: silent data loss. Startup validation warns for top-levelRelated()pairings only (a nested one isn’t detected). Need an owned child’s own endpoint (a per-row toggle)? Supported — keep the collection off the parent’s input DTO and give each field a narrow route.Collection-level exception: a field meaningful only relative to its siblings (
SortOrder) must travel on the parent DTO, so the collection can’t be omitted — guard per-row fields with aPreparehook instead (entities.patterns.md→ Owned children that are both sortable and individually togglable).Budget tally (free tier = 5 simple + 2 complex, two independent buckets — not a shared pool of 7). One slot per distinct entity type; the built-in attachment feature costs one extra simple slot per owner (the per-owner join entity registered by
HasAttachments). The sharedAttachmentbase thatWithAttachmentsregisters is free infrastructure — it costs nothing, however many owners use it. Write the tally as a comment block atop yourAdd{Entities}()extension:
Entity Classification Running tally Categorysimple 1/5 simple Productsimple 2/5 simple Ordercomplex (typed TSortBy/TIncludes)1/2 complex OrderLineowned child via e.Related()— no slot— Attachmentshared base via WithAttachments— no slot— ProductAttachmentsimple ( HasAttachmentsjoin)3/5 simple → 3 simple / 1 complex → fits free. Confirm the tally at runtime: the app logs
Regira.Entities: 3 simple / 1 complex registered → tier = freeatInformationon every start.
⚠️ The free tier caps at 7 registrations in total (5 simple + 2 complex), and the buckets don’t lend. “Promote one to complex” only helps while the complex bucket has room — it moves a slot, it never creates one. A domain with 8+ addressable entity types therefore cannot be rescued by re-classifying at all; it needs a model change or a key. Apply in this order:
| Remedy | Cost | Use when |
|---|---|---|
Role-discriminated actor — one Person / Party / Stakeholder entity with a role enum (or TPH subtype) instead of Customer + Supplier + Employee |
none — this is usually the better model, since one human is often several roles | several entities share ~the same fields and differ only in who they are to you. Ready-made slice: entities.blueprints.md — Stakeholders |
Demote to owned child (e.Related() on the parent) |
⚠️ loses its queryable endpoints — no /search, no paging, no filtering of its own rows; it is only reachable through the parent |
rows are never addressed outside their parent (order lines, join rows, per-parent settings) |
| Promote simple → complex | one complex slot; gains typed sorting/includes | the simple bucket is full and the complex bucket has room |
| Paid key | UseRegira(configuration) — §License requirement |
none of the above fits the domain |
Worked overflow. A field-service brief names Customer, Supplier, Technician, Site, Asset, WorkOrder, WorkOrderLine, Invoice. Registered naively that is 8 simple against a cap of 5 — and promoting the two allowed complex entities still leaves 6 simple. Re-classification alone cannot fix it.
| Move | Running total |
|---|---|
start — one .For<>() per name |
8 simple / 0 complex ❌ |
WorkOrderLine → owned child via e.Related() on WorkOrder |
7 simple ❌ |
Customer + Supplier + Technician → one Party + PartyRole enum |
5 simple ✅ (at the cap) |
WorkOrder → complex (typed sort + includes) |
4/5 simple (Party, Site, Asset, Invoice) + 1/2 complex ✅ |
The merge is the move that actually created capacity; the other two only rearranged it.
Every → See pointer in the steps below is one call away:
get_example(id: "Regira.Entities", pattern: "<topic>")returns just that section.
Resolve every namespace from
entities.namespaces.mdbefore writing the first entity — guessing one is the most common early misstep.
Order of work: §Step 0 (classify + tally) → the happy path below → only the optional steps a row in the table sends you to → §Steps 11–15 (wiring + runtime verification, never skipped). The step detail lives in three sections you can fetch on their own: §Steps 1–5, §Steps 6–10, §Steps 11–15.
Steps 1–15 below are the full menu (optional steps included) — treat them as a reference index; this list is the fast path through it. Most entities are simple and need only the steps below — skip everything else unless a row in the optional-steps table applies. This yields full CRUD + List + paging:
IEntityWithSerial (int key) or IEntity<TKey> (Step 1).XDto (read) and XInputDto (write) (Step 5).services.For<X>(e => { }); in a per-entity DI extension method (Steps 12 & 14).public class XController : EntityControllerBase<X, XDto, XInputDto>; (Step 13).public DbSet<X> Xs => Set<X>(); plus any relationship config (Step 11).UseEntities<TContext>(…) (Step 14).A complete copy-paste slice (every file, in order) is in entities.examples.md —
Supplier (simple) / Order + OrderLine (complex). Keep register/controller/inject aligned as you go
(N / N+2 / N) — copy a tier from the alignment card in Step 13.
Need custom filters? Add a SearchObject (Step 2) and switch to the still-simple
services.For<X, int, XSearchObject>() with controller EntityControllerBase<X, int, XSearchObject, XDto, XInputDto>.
Add SortBy/Includes enums, query builders, processors, preppers, validators, primers, reactors, normalizers, or after-mappers only when an optional step below calls for them — and note that typed sorting/includes make the entity complex (Step 0).
⚠️ For a pager, use
/search, not List.GET(List) →ListResult(items only, no count);GET /search→SearchResultwithcount. Both endpoints exist on simple and complex, so a simple entity can page too. Complexity buys typed sorting/includes and the batchPOST /list&POST /search— not the count.
The table below lists the optional steps; the required steps (1–6, 11–15) follow.
| Step | Default | Add it when |
|---|---|---|
| 7. Processors | Skip by default | You need to fill [NotMapped] or other derived values after fetching from the database |
| 8. Preppers & Validators | Skip by default | A prepper: you must compute totals/codes/FKs or manage child collections before the entity reaches EF Core. A validator: a write must be refused with a field-level 400 — a referenced row that must exist (a 400 instead of the database’s 409), a status transition, a rule on delete |
| 9. Primers & Reactors | Skip by default | A primer: you need EF Core interceptor behavior during SaveChanges() or transaction-aware stamping across modified entities. A reactor: a side effect must follow a committed change — mail, a background job, a follow-up workflow when a status changes |
| 10. Mapping & AfterMappers | Skip extra mapping config by default | DTO enrichment needs an after-mapper (UseMapping<…>().After(...)), or a nested/child mapping needs help Mapster’s convention can’t infer (AddMapping<TSource, TTarget>()) |
Mnemonic: Preppers run synchronously inside Add() / Modify() / Save(), before the change tracker — so computed values (totals, codes, FKs) are set the moment await service.Add(item) returns. Validators run right after the last prepper (and inside Remove), and refuse the write before the entity is tracked. Primers run later, in the SaveChanges interceptor, and can inspect every entity in the transaction. Reactors run last, once the transaction is committed.
The shape of the data and what the client may send. Classify first (§Step 0) — Steps 3–4 are what make an entity complex.
SetDecimalPrecisionConvention in DbContext instead of setting precision per propertyDateTime values round-trip as UTC automatically (JSON Z suffix): the UTC date convention is auto-wired by UseEntities(e => e.UseDefaults()). Standalone EF without the entities stack: .AddUtcDateTimeConvention() in AddDbContext or SetUtcDateTimeConvention in ConfigureConventionsDateOnly/TimeOnly, not a DateTime at midnight: the UTC policy touches DateTime only, so there is no zone shift to undo, and both round-trip through EF (SQLite stores text; comparisons, AddDays and .Month translate), Mapster and query-string binding (?day=2026-09-21). ⚠️ System.Text.Json reads a DateOnly only as "yyyy-MM-dd" — a client that sends a serialized JS Date ("2026-09-21T00:00:00.000Z") gets a 400. TimeOnly is written "09:30:00" and read with or without secondsInterface selection checklist:
The interface adds behaviour, never the property — declare each member yourself, with the exact type below.
| Interface | Add when… | You declare |
|---|---|---|
IEntityWithSerial |
int primary key (auto-increment). Shortcut for IEntity<int> |
int Id |
IEntity<TKey> |
Non-int primary key (e.g. Guid) |
TKey Id |
IHasTimestamps |
Track Created + LastModified (stored as UTC by default) | DateTime Created, DateTime? LastModified |
IHasConcurrencyToken |
Optimistic concurrency — a PUT/PATCH built on a stale read answers 409 instead of overwriting. Opt-in: leave it out unless the user asks for it or the app is an advanced one (several users editing the same rows, authorization or approval rules); carry ConcurrencyToken on both DTOs, no initializer (entities.patterns.md → Optimistic concurrency) |
Guid ConcurrencyToken — ⚠️ a Guid, not an ETag string or a [Timestamp] byte[] |
IArchivable |
Soft-delete — ⚠️ DELETE /{id} then flags the row instead of erasing it; the flagged rows are hidden by the archived query filter, auto-wired by UseDefaults() (Step 11); full round-trip in entities.patterns.md → Soft Delete |
bool IsArchived — keep it on TInputDto too, or restoring is impossible |
IHasTitle |
Entity has a short display name | string? Title { get; set; } (the interface is getter-only) |
IHasDescription |
Entity has a long text field | string? Description |
IHasCode |
Entity has a short unique code | string? Code |
ISortable |
Used as a sortable child collection | int SortOrder |
IHasNormalizedContent |
Entity uses normalized text for search | string? NormalizedContent + a [Normalized] source |
IHasAttachments |
Entity can have file attachments | [NotMapped] bool? HasAttachment, ICollection<TEntityAttachment>? Attachments |
IHasStartEndDate |
A validity period you filter with query.FilterIsActiveOn(date) |
DateTime? StartDate, DateTime? EndDate — both nullable |
The rest (IHasSlug, IHasUri, IHasUserId, IHasDefault, IHasObjectId<TKey>, the single-sided
IHasCreated/IHasLastModified/IHasStartDate/IHasEndDate) are in §Quick Reference: Built-in Entity
Interfaces, with the services each turns on.
IHasNormalizedContentis all-or-nothing. The globalQfilter AND-s aNormalizedContentmatch for every entity that implements it, so declaring it but leavingNormalizedContentunpopulated (no[Normalized]/normalizer) makesQmatch nothing and searches return empty. Populate it, or don’t implement the interface. The attribute belongs on the property —[MaxLength(1024), Normalized(SourceProperties = [nameof(Title), nameof(Description)])] public string? NormalizedContent { get; set; }— see §Normalizing for why the class-level form silently normalizes nothing.
→ See:
entities.examples.md— Category entity
SearchObject and add filter properties as needed.ICollection<TKey> for FK filters to allow multiple values.// Base record (SearchObject is shortcut for SearchObject<int>)
public record SearchObject<TKey> : ISearchObject<TKey>
{
public TKey? Id { get; set; }
public ICollection<TKey>? Ids { get; set; }
public ICollection<TKey>? Exclude { get; set; }
public string? Q { get; set; }
public DateTime? MinCreated { get; set; }
public DateTime? MaxCreated { get; set; }
public DateTime? MinLastModified { get; set; }
public DateTime? MaxLastModified { get; set; }
public ArchivedFilter? Archived { get; set; } // Excluded (default) | Included | Only
}
Add only new filter properties — the members above are already on the base. Re-declaring one (e.g.
Archived,Q) shadows it, which silently breaks the built-in global filters: they read the value throughISearchObject<TKey>(the empty base slot) while model binding fills the shadowed one.
→ See:
entities.examples.md— Category entity
SortBy is a plain (non-[Flags]) enumList(...) accepts IList<TSortBy>, applied left to right). The framework calls your SortBy(...) builder lambda once per value, each time passing the query already ordered by the previous values — so the lambda receives a single TSortBy? and continues the order. See the Product example.⚠️ Order via
query.OrderOrThenBy(...)/OrderOrThenByDescending(...)(Regira.Entities.EFcore.Extensions) — they start the ordering or continue it withThenByas appropriate. Never branch onquery is IOrderedQueryable<T>yourself: EF Core queries satisfy that check before any ordering is applied, so the intuitive guard compiles, passes DI validation, and throwsExpression of type 'IQueryable<T>' cannot be used for parameter of type 'IOrderedQueryable<T>' of method 'ThenBy'on the first sorted request.Scope: this is about the typed
TSortBybuilder, which is invoked once per requested sort value and must therefore continue an order it may not have started. A simple registration’se.SortBy(query => …)runs once for the whole query, so plainOrderBy/ThenByis correct there — which is what the Category example uses.
→ See:
entities.examples.md— Product entity
Decision: must a client pick individual navigations or sort keys per request? → complex
For<…, TSortBy, TIncludes>()with domain[Flags]/sort enums (only these bind?includes=/?sortBy=). Otherwise → simple: one fixed sort, and gated navigations load onDetailsonly. Either way, gate each navigation behind the flag so List/Search stay lean.
EntityIncludes is minimal (Default, All) — define a domain-specific [Flags] enum when you need named flags (Categories, Parents, etc.) and use it consistently in .For<>(), controllers, processors, and IEntityService<> injectionsDetails(id) loads all includes; List(...) loads only those passed to it (none by default). This is a service-layer default of the built-in EntityReadService/EntityRepository — Details applies the OR of every defined flag of TIncludes (alias members like All = A | B are fine), so registering a relation in Includes(...) makes it load automatically on Details. The controller Details/List/Search endpoints inherit this — on a complex base a client opts into List/Search includes via ?includes= (Details ignores the query string); a simple base binds no ?includes=, so a gated navigation there is Details-only. Tell the front-end: on complex entities, list/search responses come without nested collections unless the client sends ?includes= (a regira SPA sets it once via the entity config’s baseQueryParams).
⚠️ “None by default” means the lambda’s includes parameter is null, not the zero flag. A request with no ?includes= passes null; a client that sends ?includes=Default passes the zero flag. Both mean “nothing opted in”, so gate on the flag alone and they behave identically:
✅ if (includes?.HasFlag(OrderIncludes.Lines) == true) query = query.Include(x => x.Lines);
❌ if (includes == null || includes.Value.HasFlag(OrderIncludes.Lines)) — the null branch loads the collection on every list row, which is the heavy list this gating exists to prevent.
A to-one shown on every list row loads unconditionally; a collection is flag-gated. This is the whole includes decision, and it holds on both tiers — the simple/complex split changes only whether a client can opt in per request, never which navigations belong in which bucket. A flag on a to-one that every row renders is dead code (it always has to be on); an unconditional collection is a Cartesian row explosion waiting on Details.
Includes(...) lambda: .Include(x => x.Components!.OrderBy(c => c.SortOrder)) and query.Include(x => x.Facets!.Where(f => f.IsPublic)).ThenInclude(f => f.Facet). Archived children need no hand-written predicate: the archived filter is an EF query filter, so it propagates into every Include() and archived rows stay out of nested collections by default (entities.patterns.md → Soft Delete).Simple registrations can eager-load too — mind the asymmetry. Only the typed
TIncludesoverload is complex-only; every builder also has the untypede.Includes((query, _) => query.Include(...)). Split by trap:
- Simple bases bind no
?includes=. A flag-gated include on a simple base is Details-only — a relation that must appear on List/Search rows must be eager-loaded unconditionally: ❌if (includes?.HasFlag(All) == true) query.Include(x => x.Shopper)(blank on lists) → ✅e.Includes((query, _) => query.Include(x => x.Shopper)).- Unconditional loads are cheap to-one references — as many as the row genuinely displays — and at most one collection. References cost a join each and are what list rows are made of, so a list showing a vehicle and a supplier eager-loads both. Collections are the constraint: two or more collection navigations in one query trip EF Core’s
QuerySplittingBehaviorwarning (a Cartesian row explosion). Any aggregate root with several owned collections hits this on Details, which loads the OR of every registered include by design — so it is the normal case, not a hierarchy edge case. Fix it where you see it: append.AsSplitQuery()inside theIncludes(...)lambda, or set it once on the provider (opt.UseSqlite(cs, o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery))). Deeper graphs:entities.patterns.md— Hierarchical Data.- Eager-loads go in
Includes(...), neverFilter(...). Filters decide which rows qualify; includes decide what’s loaded on them. An eager-load hidden in a filter is invisible to everything that reasons about includes.EntityIncludes(or a domain[Flags]enum) is the opt-in contract. Gate every collection or expensive navigation behind the flag (if (includes?.HasFlag(EntityIncludes.All) == true) query = query.Include(...)): it still auto-loads on Details while staying off List/Search until the client opts in.
?includes=value contract. Entities on the genericEntityIncludesaccept onlyDefault/All; a specific member name (Categories,Parents, …) is accepted only by entities that expose a named[Flags]includes enum. The front-end scaffold’s--reldefaults to["All"].
⚠️ One
Includes(...)registration per entity — a second call replaces the first (last-write-wins). The builder registers a singleIIncludableQueryBuilder, so DI resolves only the last registration: ❌e.Includes((q, _) => q.Include(x => x.Owner)); e.Includes((q, i) => …)silently dropsOwnerfrom every response. Compose everything in one lambda — unconditional references first, flag-gated collections behind their checks. (Filters differ: everyFilter(...)/AddFilter<>()registration runs.)
→ See:
entities.examples.md— Order + OrderLine entities (typed) / Category & Supplier entities (untyped)
Id in InputDto to support the Save (upsert) actionCreated, LastModified, NormalizedContent) from InputDtoPassword) from DTOsIsArchived is the exception — keep it on TInputDto. It reads like an auto-generated and server-owned flag, so it gets excluded by reflex. The write path stays archived-inclusive either way, but a DTO that cannot express the flag can never clear it, so restore becomes impossible — and the row is invisible meanwhile (lists hide it, GET /{id} 404s). Generated forms hide the field; they don’t drop it. Round-trip: entities.patterns.md → Soft DeleteOrder.Code, computed totals, and per-line prices — an order/invoice line’s UnitPrice is a textbook price-tampering vector) stay off InputDto and are set server-side. Declare each one [ServerOwned] (or e.ServerOwned(x => x.Code, mint) to mint on create as well) and the write path restores it from the stored row on every update — recipe belowCategoryTitle, but use Category=>Title)InputDto when they are configured with e.Related(...) — declare them
nullable and uninitialized (ICollection<LineInputDto>? Lines { get; set; }), never = []. An omitted
collection then maps as null (untouched); an initialized/non-nullable property defaults to [] = delete-all
on any save that doesn’t send it (a status-only PATCH silently clears the rows)AfterMapper for computed/calculated properties (e.g. URLs, display names) in DTO⚠️ Exclude a server-owned field and restore it in the same edit. A field absent from
TInputDtomaps asnull/default on every PUT and PATCH — 200 OK, silent corruption (a status-only PATCH zeroes a computedTotal). Declare it server-owned:public class Order : IEntity<int> { public int Id { get; set; } [ServerOwned] public string? Code { get; set; } // restored from the stored row on update [ServerOwned] public decimal Total { get; set; } public string? Status { get; set; } // client-writable } // mint on create too (the attribute carries no lambda), and on an owned child: e.ServerOwned(x => x.Code, _ => GenerateCode()) .Related(x => x.Lines, r => r.ServerOwned(x => x.UnitPrice));
[ServerOwned](namespaceRegira.Entities.Attributes) is enforced by a prepperUseDefaults()registers, so it guards theIEntityServicewrite path and leaves a workflow service’s raw-DbContextwrite alone. It has no bypass: your own domain action saving throughIEntityServiceis reverted like any client. Scalars and FKs only — a navigation andIArchivable.IsArchivedare rejected (a restore has to be able to clear that flag).On create the guard does nothing: whatever value the entity already carries is inserted as-is, which is what lets a seeder back-date a
Codeor import a historical rating.e.ServerOwned(x => x.Code, mint)fills it only when unset, so a supplied value survives that form too. The protection is update-only.
What you need Use Protect on update; optionally mint on create from the entity itself [ServerOwned]/e.ServerOwned(…)Mint from an injected service, or re-derive the value on every save a prepper ( EntityPrepperBase<T>;originalisnullon create, the stored row on update)Stamp the field even when a raw- DbContextwriter creates the row (Created)a primer — but it also reverts such a writer’s updates (§Step 9) Mint a sequential code that must not skip numbers a primer: the mint and preppers run before the validators, so a create they refuse has already used its number ( entities.patterns.md→ Server-generated sequential codes)Only a gated domain action may change it ( Statuson approve)a prepper that restores it unless a scoped trusted-writer flag is set — entities.patterns.md→ Role-gated transitions (server-owned state)Owner-stamp from the claim, computed totals, and the primer form:
entities.patterns.md→ Server-owned / immutable fields on update.
→ See:
entities.examples.md— Category entity
All optional except Step 6. Reach for one only when a row in the optional-steps table (§Entity Implementation Workflow) applies — and read §Step 0’s “who writes it?” decision before adding anything that touches a child collection.
→ See:
entities.examples.md— Product entity
→ Apply the inline vs separate class rule from §Decision-Making Guidelines.
Choosing a base: inherit
FilteredQueryBuilderBase<TEntity, TKey, TSearchObject>for the common case — it passes the query through unchanged and you overrideBuild(...). Implement theIFilteredQueryBuilder<…>interface directly only when you want full control with no inherited behavior. Both live inRegira.Entities.QueryBuilders.Abstractions(see Troubleshooting if you hit CS0246).
⚠️ Can’t Filter/SortBy on processor-populated [NotMapped] values — they’re filled after the SQL fetch, so they aren’t in the query. Use the underlying DB expression instead (e.g. OrderByDescending(x => x.Projects.Count), not the processor-filled count field).
Don’t write a
Qfilter. Keyword search onQis already global forIHasNormalizedContententities (UseDefaults()registersFilterHasNormalizedContentQueryBuilder); a per-entityQfilter ANDs on top of it and narrows or empties results — see §Filtering with Normalized Content. Keep your query builder for domain filters (category, price, …) only.
SortBylambda signature depends on theFor<>overload:
- Simple builders (no
TSortBy—For<TEntity>,For<TEntity, TKey>,For<TEntity, TKey, TSearchObject>) →e.SortBy(query => query.OrderBy(...))- Complex builders (with
TSortBy—For<TEntity, TSearchObject, TSortBy, TIncludes>) →e.SortBy((query, sortBy) => ...)Using the two-arg form with a simple builder produces CS1593.
Builder verb = inline lambda vs registered class. The bare verbs take an inline lambda; the
Add*<T>()verbs register a builder class. ⚠️ An inline lambda receives only its declared arguments — there is no DI and noDbContextin it (Filtergets(query, searchObject),Includesgets(query, includes); onlyPreparehas a(entity, dbContext)overload). Anything needing an injected service —IQKeywordHelper, a domain service, another entity’s repository — or a cross-entity query belongs in the registered-class form, which constructor-injects normally. There is noe.Filter<TService>((q, so, svc) => …)overload. (new QKeywordHelper()inside a lambda compiles — its constructor parameters are optional — but it silently bypasses the app’s configuredQKeywordHelperOptionsand normalizer, so its keywords stop matching what was stored. Resolve the service, don’t re-newit.) The complete overload set per verb is inentities.signatures.md→For<>()overload → builder. Inline:e.Filter((q, so) => …)/e.SortBy(...)/e.Includes((q, inc) => …). Class:e.AddFilter<TBuilder>()/e.AddSortBy<TBuilder>()/e.AddIncludes<TBuilder>().e.Includes<TBuilder>()(noAdd) does not exist — that name is the inline overload, so passing a type argument is CS0308. Compose all eager-loads in oneIncludes/AddIncludesper entity — a second registration replaces the first.
⚠️
Buildis synchronous —IQueryable<TEntity> Build(IQueryable<TEntity>, TSearchObject?), noCancellationToken, no async overload. It composes an expression; it must not execute one. A filter that needs data it has to fetch (expanding a picked category to its descendants, resolving a permission list) has no hook here, and blocking on.ResultinsideBuilddeadlocks or serializes every request. Two sanctioned answers: express it as a subquery in the sameIQueryable(q.Where(x => ctx.Categories.Where(…).Select(c => c.Id).Contains(x.CategoryId))— one round trip, and recursive shapes can use a mapped DB function, seeentities.blueprints.md→ Recursive entities), or resolve the set before the request reaches the filter and pass it as an ordinaryICollection<TKey>on the SearchObject (?categoryId=1&categoryId=7&…), which keeps the API honest and puts the walk where the graph is already loaded.
IEntityProcessor<TEntity, TIncludes> (Optional)A processor fills values after a read — [NotMapped] fields, computed totals — by implementing
IEntityProcessor<TEntity, TIncludes> (Regira.Entities.Processing.Abstractions), or by deriving from
EntityProcessor<TEntity, TIncludes> (Regira.Entities.EFcore.Processing), which takes the work as a delegate or
as an override of its virtual Process.
There is no EntityProcessorBase.
⚠️ A processor runs only in its OWN entity’s read pipeline. It is invoked on the materialized root result of that entity’s Details/List — it never walks into navigation properties. So a Session row arriving nested inside GET /events/1?includes=Sessions is materialized by Event’s read service and never enters IEntityProcessor<Session, …>: its [NotMapped] values are null there, while the same row fetched from /sessions carries them. Don’t render such a field as 0 on a nested row — check for null and show nothing, or fetch the child list separately.
The same scoping applies to after-mappers (Step 10): IEntityAfterMapper is dispatched against the root source only, so a DTO produced inside another entity’s projection does not get its after-mapper fields either.
→ See:
entities.examples.md— Category entity (CategoryProcessor) / Additional Patterns > Inline processor
Use a prepper to: manage child collections (if not using e.Related()), recalculate totals/codes/FKs before SaveChanges(). Refusing a write is a validator’s job (§Validators below).
⚠️ On update, modified carries only what TInputDto sent. Every other field is default — a required
FK left off the DTO, a [ServerOwned] value, and any timestamp a primer stamps (primers run after every
prepper, inside SaveChanges, so modified.Created is default(DateTime) here even on a row created a year
ago). Read those from original, never from modified with a == default fallback: that fallback is
silent, plausible and wrong — an SLA deadline derived from modified.Created drifts forward by the row’s age
on every edit, and the response is a 200. Restoring a value is the same move as any server-owned field
(entities.patterns.md → Server-owned / immutable fields on update). A required FK
that is on the DTO reaches the database unchecked: an existence check in a validator turns its 409 into
a field-level 400 (§Validators below).
⚠️ A prepper enforcing a cap must count the change tracker’s pending rows too. A capacity, quota or
duplicate check written as a dbContext.Set<T>().CountAsync(...)/AnyAsync(...) cannot see rows queued in
the same SaveChanges() batch, so every row in a bulk wave sees the last-flushed count and the cap is never
reached — no error, just collectively wrong data. Add dbContext.ChangeTracker.Entries<T>() entries in
EntityState.Added to the persisted count (§Seeding via IEntityService has the full rule). This is not a
seeding-only concern: a batch import, a POST /list write and two concurrent requests have the same shape.
⚠️ Stamp a server-owned timestamp or code only when the value is absent, as HasCreatedDbPrimer does —
an unconditional DateTime.UtcNow on create overwrites back-dated seed data
(entities.patterns.md → Back-date Created / LastModified when seeding).
→ See:
entities.examples.md— Additional Patterns > Prepper
Variants: inline (simple), inline with original (create vs update), inline with DbContext, separate class, e.Related(x => x.ChildCollection) shortcut.
Two shapes, and the choice is forced by whether you need the stored row:
e.Prepare(...) — the builder delegate. Overloads are Action<TEntity> and Func<TEntity, TContext, Task>; there is no one-arg async form, so a single-parameter lambda ending in return Task.CompletedTask; picks the Action and fails CS8030 — drop the return, or take the TContext parameter. The delegate never sees the original entity.EntityPrepperBase<TEntity> + e.AddPrepper<T>() — Prepare(modified, original, token). This is the only shape that can compare against the stored row, so anything create-vs-update (restoring server-owned fields, diffing quantities) belongs here.Write-pipeline order — one save, fixed order. It matters as soon as an entity carries more than one of these, which is the normal case (e.Related() is a prepper):
| # | Stage | Runs where | Sees |
|---|---|---|---|
| 1 | DTO → entity mapping | the controller / your caller | the request payload |
| 2 | Preppers, in registration order — e.Prepare(...) delegates, e.AddPrepper<T>() classes, and the Related() collection sync alike |
IEntityService.Add/Modify/Save, before SaveChanges() |
the mapped entity, plus the stored original on the EntityPrepperBase shape |
| 3 | Validators, every one in scope — e.Validate(...) delegates, e.AddValidator<T>() classes and the global ones alike |
IEntityService.Add/Modify/Save after every prepper, and Remove |
the entity as the preppers left it, and the stored original on Modify; all of their errors reach the client as one 400 (§Validators below) |
| 4 | SaveChanges() |
your call (base controllers make it for you) | — |
| 5 | Primers | an EF SaveChangesInterceptor, inside the save |
the EntityEntry, so entry.State and entry.OriginalValues |
| 6 | Reactors, in registration order | after the commit, in a DI scope of their own | an IEntityChange<T>: the committed row and the stored one (§Step 9 → Reactors) |
What a prepper works with: modified is the mapped incoming entity — the instance EF tracks and saves, so a value a prepper sets on it (or on one of its incoming owned rows, before or after the sync) is what gets written. original is a no-tracking copy of the stored row, loaded like Details (every include flag). The Related() sync never rewrites original’s collection: it holds the stored rows when your Includes loads that navigation and null otherwise — on either side of the sync. The sync only sets EF states: incoming rows become Added/Modified, stored rows missing from the payload Deleted, and a null collection is left untouched (a prepper registered before Related() that sets it to null keeps the rows from changing on that save). A primer sees the entity after every prepper has finished with it. No prepper runs on DELETE; validators do, with ctx.Operation == EntityWriteOperation.Remove — the place for a rule that forbids deleting a row in some state (§Validators below). Primers see Deleted entries. Only stages 5 and 6 run for a writer that bypasses IEntityService and saves through the raw DbContext — the reason a field a workflow service legitimately writes belongs in a prepper, never a primer (entities.patterns.md → Server-owned / immutable fields on update).
e.Related() takes an optional parent-level prepareFunc followed by an optional configure callback — signature Related<TRelated, TRelatedKey>(x => x.Collection, prepareFunc?, configure?):
e.Related<TRelated, TRelatedKey>(x => x.Collection, prepareFunc?) — syncs the collection, optional per-entity prepare.e.Related<TRelated, TRelatedKey>(x => x.Collection, configure: builder => { ... }) — use RelatedEntityBuilder to nest sub-collections (builder.Related(...)) or add item-level prepare logic (builder.Prepare(...)). Pass prepareFunc before configure to combine both. Worked two-level example (party relationships carrying their own contact data): entities.blueprints.md — Stakeholders (§Registration).Single-arg shortcut for int-keyed children: when the related entity has an
intkey, drop the second type argument —e.Related<TRelated>(x => x.Collection, …). This shortcut is available on all int-key builders, including the simpleFor<TEntity, int, TSearchObject>()registration. The two-arg forme.Related<TRelated, TRelatedKey>(…)is only required for non-intrelated keys (type inference can’t deduceTRelatedKeyfrom a navigation expression alone — CS0411 if both args are omitted with a non-int key).
How the sync classifies an incoming row — three cases, and the first is what the front end sends:
Incoming Id |
Treated as | Note |
|---|---|---|
0, null, or any negative number |
new → INSERT |
A negative id is a client temp key (the Vue useOwnedCollection mints them so a row added in the session has a stable :key); it is cleared before insert, so the store generates the real one |
| matches an original row | update | fields absent from the child input DTO reset to default |
| positive but matching nothing | new → INSERT, ⚠️ with that id |
Only temp (negative) keys are cleared, so a store-generated key arrives as an explicit insert: a silently wrong PK on SQLite, and “Cannot insert explicit value for identity column” on SQL Server. It means the client is sending a stale id for a row someone else deleted — reload before saving rather than relying on the server to absorb it. (A client-assigned key type — Guid, string — is never cleared, because there a chosen key is the point.) |
The parent FK needs no stamping. New children reach the store through the parent’s navigation, so EF assigns the FK once the parent’s key is generated — this holds for a brand-new parent saved with children in one call. Setting the FK yourself to a parent id that does not exist yet (0) is what breaks it.
A validator checks the entity a write is about to save or remove, and refuses the write by adding errors. It runs
inside Add / Modify / Save after every prepper — the global ones and the Related() sync included,
whatever the registration order — and inside Remove, where no prepper runs. Every validator in scope runs and
adds to one list; when any error was added, the write service throws one EntityInputException<TEntity> of the
entity it saves, before the entity is tracked, and the client gets every error in one 400 (§Error Handling).
Validators read and never write: ctx.Item is the instance that gets saved, so a value a validator sets is still
written — changing the entity is a prepper’s job.
// inline — the second form receives the request's DbContext and the write's cancellation token; async ctx => … takes neither
// the write awaits an async delegate, so an error added after an await still refuses it
e.Validate(ctx =>
{
if (ctx.Operation == EntityWriteOperation.Remove && ctx.Item.Status == OrderStatus.Shipped)
ctx.AddError(nameof(Order.Status), "A shipped order cannot be deleted.");
});
e.Validate(async (ctx, db, token) =>
{
// db sees every row: with scoped reads (tenants, owners), see the row-security note below
if (ctx.Operation != EntityWriteOperation.Remove && !await db.Customers.AnyAsync(c => c.Id == ctx.Item.CustomerId, token))
ctx.AddError(nameof(Order.CustomerId), $"Customer {ctx.Item.CustomerId} does not exist.");
});
// class form — constructor-injected, with a per-item opt-out
public class OrderStatusValidator : EntityValidatorBase<Order>
{
public override bool CanValidate(Order item) => item.Status != OrderStatus.Pending;
public override Task Validate(IEntityValidatorContext<Order> ctx, CancellationToken token = default)
{
if (ctx.Original is { } stored && !OrderStatusRules.CanMove(stored.Status, ctx.Item.Status))
ctx.AddError(nameof(Order.Status), "Status change not allowed.");
return Task.CompletedTask;
}
}
e.AddValidator<OrderStatusValidator>();
options.AddValidator<TenantValidator>(); // global — an EntityValidatorBase<IHasTenantId> checks every tenant-owned entity
What a validator receives — IEntityValidatorContext<TEntity>:
Item — the entity about to be written, as the preppers left it; for Remove, the row as stored (the caller’s
instance when none is found), so a delete by key — Remove(new Order { Id = id }) — is judged by the row’s state.Original — the row as stored, on Modify; null on Add and Remove. A Modify whose row is not found
runs no validator and answers null (not found), so Original is never null on a Modify.Operation — EntityWriteOperation.Add, Modify or Remove. A soft delete of an IArchivable is a Remove.AddError(key, message, args?) — key is the property path (CustomerId, Lines[0].Quantity), "" for the
entity as a whole; a key may carry several messages. Errors holds what every validator of this write added so
far, so a later one can skip an expensive check: no point querying for the customer when CustomerId already
failed.The message can be a translation key. What a validator adds is up to it: a text the client shows as is
("A code is required."), or a key the client translates ("ValueTooLarge"). A Regira front-end looks every
message up in its translation messages: when it finds one, it shows that translation, each {name} placeholder filled
from args; when it does not, it shows the message as is. args is an anonymous object or a dictionary; only scalar
values — text, numbers, booleans, dates, times, Guids and enums — reach the client.
ctx.AddError(nameof(Order.Total), "ValueTooLarge", new { max = 10_000 });
// → 400: "errors": { "Total": ["ValueTooLarge"] },
// "errorDetails": [{ "key": "Total", "message": "ValueTooLarge", "args": { "max": 10000 } }]
// front-end messages: "ValueTooLarge": { "en": "At most {max}", "nl": "Maximaal {max}" }
A key is only as readable as its name to a client without the translation — another API, a log — so choose it to
read as the rule (ValueTooLarge, order.notOpen). The 400 body is in §Response Types.
Which validators run — the scope rule. A validator applies to an entity when the TScope of its
IEntityValidator<TScope> is that entity, a base class of it or an interface it implements:
| Scope | Runs for |
|---|---|
EntityValidatorBase<Order> |
Order only |
EntityValidatorBase<Party> |
Person and Organization, whichever service saves them |
EntityValidatorBase<IHasTenantId> |
every tenant-owned entity |
Person saved
through the Party service gets its Person validators too. The exception is still the service’s own
EntityInputException<Party>, so the generated endpoint’s typed catch matches.EntityValidatorBase<IHasCode> added inside For<Order>() checks every entity that has a
code. Register an interface- or base-scoped validator once, with options.AddValidator<T>() in UseEntities();
to keep one on a single entity, scope it to that entity’s type. A class registered twice runs once; each
Validate(...) delegate is a validator of its own.Related() sync writes — validate Lines from the Order validator (key Lines[0].Quantity). A validator scoped
to a child without a For<>() never runs; startup validation warns — except for FluentValidation (below).DbContext skips row security. Global filters (tenant, owner) scope the entity
services’ reads, not db: db.Customers.AnyAsync(…) sees every tenant’s rows, so it accepts another tenant’s
CustomerId, and its 400 tells the client that id exists. Where reads are scoped, check the reference through the
filtered read service — a class validator taking IEntityReadService<Customer, int> and refusing when
await customers.Details(id, token) is null — or repeat the scope’s predicate in the query. Never inject
IEntityService<> into a validator: its write service imports every validator, so the container meets a circular
dependency and no entity service in the app resolves.db track nothing while the validators run, Find / FindAsync included, so a lookup that returns
the row being written — a uniqueness check db.Orders.FirstOrDefaultAsync(o => o.Code == ctx.Item.Code, token) on a
PUT that keeps its code — leaves the write free to track its own instance. Only an explicit AsTracking() opts
back in, and a tracked copy of the row being written then makes the write throw “cannot be tracked because another
instance with the same key value … is already being tracked”, a 500.What a refusal leaves behind. A refused Add or Modify takes back what its preppers marked — the rows a
Related() sync added, changed or deleted — and leaves the item itself untracked, even one the job loaded with tracking
and edited. A prepper’s plain edit to another row the scope already tracked stays, since EF notices it only at
SaveChanges(): keep a prepper to the item and its own children. A refused Remove is checked before anything is
marked.
Where they run. EntityWriteService runs them; a subclass must take IEnumerable<IEntityValidator> and pass it
to the base constructor — the constructor without it runs none. A custom IEntityRepository over another store
imports IEnumerable<IEntityValidator> and calls validators.ValidateItem(item, original, operation) before it
writes — for a delete, with the stored row. An override of Remove that skips base.Remove skips them, as an Add override skips the preppers; mark
extra rows for a delete in an override of RemoveItem, which runs once the validators passed. Startup validation
warns about a write path that cannot run the validators in scope.
FluentValidation — package Regira.Entities.Validation.FluentValidation runs AbstractValidators in this stage,
under the same scope rule: entities.patterns.md → Input validation with FluentValidation.
⚠️ An AbstractValidator<T> runs only when a write service saves a T. One for a Related() child
(AbstractValidator<OrderLine>) or for an input DTO is registered by the assembly scan and never runs, with no
startup warning — validate children from the parent: RuleForEach(x => x.Lines).ChildRules(...) or
.SetValidator(new OrderLineValidator()).
| Relationship | Use Related()? |
Notes |
|---|---|---|
| Owned child list (e.g., order lines) | ✅ Yes | Child has no own .For<>() registration; lifecycle is fully controlled by the parent |
Optional parent FK (e.g., InvoiceId? on Intervention) |
❌ No | Manage via the child entity’s own service. A total the parent rolls up from these children is a prepper reading the persisted rows — entities.patterns.md § Aggregates over a non-owned child collection. Two consequences decide whether you want this: the total is eventually consistent (it settles when the parent is next saved, so moving a child must save the parent too), and seeding needs a second pass re-saving every parent |
| Many-to-many join entity | ✅ Yes | The join entity itself is owned; see join-entity example in entities.examples.md |
| Independent entity with back-ref collection | ❌ No | Use Include() in the query builder to load the navigation property |
An owned child/join entity still implements
IEntityWithSerial(orIEntity<TKey>) — it skips only its own.For<>()registration, not the entity interface.e.Related<TRelated>()constrainsTRelated : IEntity<int>.
⚠️ One writer per save path. A parent’s
Related()sync and the child’s ownIEntityService<T>may coexist — the registrations don’t conflict — but only while the parent’s input DTO leaves the collectionnull, which makes the sync short-circuit to a no-op. Send the collection and the parent wins: its next save re-diffs and silently overwrites rows the standalone service wrote (null= untouched,[]= all rows deleted). Need independent endpoints for an owned row? That’s the supported join-toggle recipe inentities.patterns.md— Single-field PATCH / state toggle; just keep the collection off the parent’sTInputDto. Unless the child is also sortable:SortOrderis collection-level and must ride the parent DTO, so guard the per-row field with aPreparehook instead (same file → Owned children that are both sortable and individually togglable).
⚠️ Calling
Modifyfrom your own action?null= untouched is the sync’s contract, and EF gets there first. On a tracked parent loaded withInclude(x => x.Items), settingItems = nullto no-op the sync orphans the children before the sync is ever consulted — they cascade away onSaveChanges, 200, no error. HandModifya detached instance (AsNoTracking(), ornew Cart { Id = id, …, Items = null });[ServerOwned]restores what you left off.
Related()children cost no budget slot. A child collection managed viae.Related(...)gets no.For<>()and no controller — it rides on the parent’s endpoints. GivingOrderLineits own registration is the classic way to blow the free-tier bucket for nothing (see Step 0). Editing a m2m join from a SPA: bind the join rows through the related entity’s picker — see the front-end guide (regira_modules.vue.entities→ patterns → Editing a many-to-many join).
Set
OnDeleteintent per FK. Choose the behavior deliberately inOnModelCreating—Cascadeto remove dependents with the parent, orRestrictso a referenced row can’t be deleted. A database constraint violation (FK, unique index) surfaces as 409 Conflict on the base controllers (§Error Handling); check it in a validator when the client should get a field-level 400 instead — a validator also runs on the delete aRestrictFK refuses (§Step 8 → Validators).⚠️ A join entity has two FKs and they usually want different behaviour. On the owner side (
ProductCategory.Product)Cascadeis almost always right — the join rows are part of the product. On the lookup side (ProductCategory.Category)Cascademeans deleting one category silently strips it from every product that had it: 200 OK, no 409, no warning, and the only trace is rows that quietly disappeared. PreferRestrictthere when you want the delete refused while the lookup is in use. TheProductCategoryexample inentities.examples.mdcascades on both sides because both ends are disposable there — don’t copy it onto a lookup you care about.⚠️ On SQLite that guarantee is off by default.
Microsoft.Data.SqliteleavesPRAGMA foreign_keysdisabled unless the connection string saysForeign Keys=True, soRestrictnever fires and the delete succeeds silently — seeentities.setup.md→ P3 for the connection string.
Run during SaveChanges() via EF Core interceptors; can inspect other modified entities in the same transaction. The interceptor is auto-wired by UseDefaults(); without it, select e.WireDbContext(DbContextWiring.PrimerInterceptors).
Primers — like the normalizer and auto-truncate interceptors — run on SaveChanges() and SaveChangesAsync() alike, so a synchronous save (a seeder, a job, your own SaveChanges override) archives, stamps and primes exactly like the async write path. On the synchronous call a primer that awaits is waited on without the caller’s synchronization context, so its own awaits do not deadlock the caller — it still holds the calling thread for its I/O, and a primer that blocks on something the caller holds deadlocks as any blocking code would — prefer SaveChangesAsync() when primers do I/O.
⚠️ A primer runs on every
SaveChanges()(it’s an EF interceptor); a prepper runs only on theIEntityServicewrite path. So a primer that restores a server-owned field fromentry.OriginalValuesalso fires on — and reverts — a domain/workflow service’s raw-DbContextwrite. When a second writer legitimately owns a field (a status/state machine), guard it with a prepper —[ServerOwned]/e.ServerOwned(…)is that prepper in declarative form (§Step 5), orEntityPrepperBase<T>when you need the full stored row — not a primer. The choice table is inentities.patterns.md→ Server-owned / immutable fields on update; the full second-writer treatment is under → Server-generated sequential codes (Primer vs prepper when a second writer exists).
→ See:
entities.examples.md— Additional Patterns > Primers
A reactor runs once the save that changed a row is committed: the place for a side effect that must not happen for a write that fails or rolls back — mail, a call to another system, a background job, a follow-up workflow when a status changes. What has to be part of the save itself (a history row written atomically with the change) stays a primer.
// inline — the reaction's own scoped IServiceProvider is the second argument
e.React(x => x.Status, OrderStatus.Shipped, (change, services, token) =>
services.GetRequiredService<IOrderMailer>().SendShipped(change.Entity.Id, token));
// class form — constructor-injected, with its own CanReact rule
public class OrderShippedReactor(IOrderMailer mailer) : EntityReactorBase<Order>
{
public override bool CanReact(IEntityChange<Order> change) => change.ChangedTo(x => x.Status, OrderStatus.Shipped);
public override Task React(IEntityChange<Order> change, CancellationToken token = default)
=> mailer.SendShipped(change.Entity.Id, token);
}
e.AddReactor<OrderShippedReactor>(); // per entity — reacts to this entity only, even when written against an interface
options.AddReactor<AuditReactor>(); // global — an EntityReactorBase<IHasTimestamps> reacts to every entity implementing it
What a reactor receives — IEntityChange<TEntity>:
Kind — Added, Modified or Deleted. A soft delete of an IArchivable is Modified:
change.ChangedTo(x => x.IsArchived, true).Entity — the row as committed, store-generated keys filled in (for Deleted, as it was stored); Original
— the row as stored before the save, null for Added. Both are detached snapshots of the scalar and complex
properties: navigations are not loaded (re-read through a service for them), and changing them persists nothing.change.HasChanged(x => x.Status) / ChangedProperties — Modified rows only; Added and Deleted report
none. change.ChangedTo(x => x.Status, value) covers both an insert holding the value and an update that
brought it there — the usual trigger for a pipeline.IEntityService.Modify or a Related() sync loaded, already holds them. Any other write — Update() of a
detached entity, a stub Attach(new Order { Id = id }) then edited, a stub Remove(...), a delete through the
service — has its rows read during the save, one query per entity type, and only for entity types a reactor is
registered for. ExecuteUpdate / ExecuteDelete bypass the change tracker: no reactor (and no primer) sees them.How reactors run:
Commit() for every save
inside an explicit BeginTransaction(), also when several contexts share that transaction through
UseTransaction and one of them commits it; when an ambient TransactionScope completes. A failed save, a
rollback and a transaction disposed without committing react to nothing. Not seen: a rollback to a savepoint
(the reactions of the saves made after it still run), and a commit made on the DbTransaction itself rather than
through a context UseEntities wires (its reactions never run). A transaction begun outside EF and handed to
UseTransaction must also commit or roll back through EF: on Npgsql, one disposed without either leaves its
reactions to the next such transaction on that pooled connection.SaveChanges() for a
save that commits on its own; Commit() / CommitAsync() for the saves inside BeginTransaction(), whose own
SaveChanges() returns before anything reacts; the Dispose() that ends a completed TransactionScope, which
runs them synchronously. Hand slow or retryable work to a job system: the reactor only enqueues it
(IBackgroundJobClient.Enqueue(...)).DbContext: a reactor that
writes calls SaveChanges() itself, and that save triggers the reactors of what it wrote. Nesting stops at 8
levels with a logged error — guard CanReact so a chain ends.CancellationToken does not cancel them.SaveChanges() — the IEntityService path and raw DbContext writers, sync and async alike.
Wired by UseDefaults() (DbContextWiring.Reactors); à la carte, add that flag to e.WireDbContext(...).→ See:
entities.examples.md— Additional Patterns > Reactors
Mapping configuration is optional. With Mapster (the default), TEntity ↔ TDto/TInputDto mapping — including nested objects and child collections — works by convention whenever the DTO shape resembles the entity. Most entities need no mapping statements at all (the Category, Product and Order examples register none and still round-trip their nested collections). Add configuration only for the specific cases below.
UseMapping<TDto, TInputDto>() — call it when you need to attach an after-mapper or customise the top-level mapping. It registers TEntity→TDto + TInputDto→TEntity and returns a builder you chain the after-mapper onto:
.After(...) — enrich the output DTO after Entity→DTO mapping (computed properties, URLs).AfterInput(...) — modify the entity after InputDto→Entity mapping.After<TAfterMapper>() — separate class when DI is needed; options.AddAfterMapper<T>() registers a global one. The class-based overload returns the untyped builder (drops TDto/TInputDto), so .AfterInput(...) no longer compiles after it (CS1061) — chain the typed .After(...)/.AfterInput(...) inline when you need both (e.g. a renamed DTO collection — see entities.patterns.md — Renamed DTO property)AddMapping<TSource, TTarget>() — an escape hatch. Register an explicit mapping for a specific (usually nested/child) type pair only when Mapster’s convention produces the wrong result — e.g. a child DTO whose shape diverges from the entity, or a child input type that needs a custom mapping. It is not required to project nested related collections; Mapster does that by convention.⚠️ An after-mapper runs for the read root only (§Step 7): the same DTO nested inside another entity’s projection is mapped without it. So a computed field that must also appear on, say, a
PersonCoreDtocarried by everyTicketDtocannot come from an after-mapper — put it on the entity as a[NotMapped]getter and let Mapster project it. The failure is silent in the worst way: the field populates on/peopleand is blank on every ticket row, board card and comment, and nothing — compiler, DI validation, or a single-page smoke test — reports it.
Empty nested collection in a response? That is almost always a missing
Includes(the navigation was never loaded from the database), not a missing mapping — see §Step 4 and Troubleshooting. Checke.Includes(...)before adding anyAddMapping.
→ See:
entities.examples.md— Additional Patterns > Mapping / AfterMapper
Never skipped. Steps 12–13 are where the generics must line up (register = N, controller = N+2, inject = N) and Step 15 is the only place a wrong pipeline actually shows.
Add DbSet<YourEntity> and configure any relationships in OnModelCreating.
Any
IArchivableentity? Nothing to add here —UseEntities<TContext>(e => e.UseDefaults())wires the archived query filter into the context’s options (Step 12), and it is applied after everythingOnModelCreatingconfigured. ⚠️ Two cases still need a call: aDbContextyou construct yourself (new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()…Options)— tests, design-time factory, seeding tool) takes.AddArchivedQueryFilter()on those options, and a setup that opted out ofDbContextWiring.ArchivedQueryFilterendsOnModelCreatingwithmodelBuilder.SetArchivedQueryFilter()(Regira.Entities.EFcore.Extensions) instead — after your ownHasQueryFilter(...)calls, exactly once. Startup validation reports a model that ends up without the filter as an error naming the entity. Full round-trip:entities.patterns.md→ Soft Delete.
Then expect an EF warning (
net10.0) — and read it before suppressing it. Because a real query filter is installed there, EF logs onePossibleIncorrectRequiredNavigationWithQueryFilterInteractionWarningper relationship whose required principal isIArchivable— “Entity ‘Order’ has a global query filter defined and is the required end of a relationship with ‘OrderLine’“. Onnet8.0it cannot appear at all (no filter is installed there), so don’t go looking for it.⚠️ The warning is benign for an aggregate parent and a silent data bug for reference data. The filter propagates into
Include(...), and where the navigation is required EF composes it as an inner join — so the dependents drop out of items while the count query (no includes, no join) still counts them. ForOrder→OrderLinethat is the intent. For aCategorythat fifty separately-registeredAssetrows point at, archiving one silently removes those fifty from every list while/searchkeeps reporting them: short pages, no error, nothing logged. Reference data behind a required FK should not beIArchivable— use a realDELETEwithOnDelete(Restrict)(→ 409 while in use), or make the FK optional. Startup validation warns on that shape; the aggregate-parent case is the one the golden path’s startup-warning checkpoint expects. Full decision table:entities.patterns.md→ Soft Delete.Once you have confirmed it is an aggregate parent, silence it where the context is registered (the options builder, not
OnModelCreating):using Microsoft.EntityFrameworkCore.Diagnostics; // CoreEventId builder.Services.AddDbContext<AppDbContext>(opt => opt .UseSqlite(cs) .ConfigureWarnings(w => w.Ignore(CoreEventId.PossibleIncorrectRequiredNavigationWithQueryFilterInteractionWarning)));Mirror the parent’s archived filter onto a dependent? Per dependent:
The dependent… Mirror HasQueryFilter(x => !x.Parent!.IsArchived)?is queried directly (own endpoint, /search, an aggregate over it) and is one hop from the parentYes — otherwise its countanditemsdisagree. No?archived=opt-in lifts it (the opt-in acts on the queriedIArchivableentity only), so rows that must stay readable after the parent is archived call for keeping the parent out of their reads instead —entities.patterns.md→ Soft Deleteis reached only through the parent’s Include(...)No — the parent’s filter already removed it sits two hops away ( !x.Session!.Event!.IsArchived)No — needs APPLY, which SQLite lacks. Filter explicitly in the queries that careIt never collides with the named archived filter, because that one only touches
IArchivabletypes.⚠️ A mirrored filter leaves the dependent’s own routes. EF inlines it into every correlated
Any(x => …)over that entity from anywhere else — including the parent’s search (Assignments.Any(a => a.ReturnedOn == null)). One hop inlines as a join and is safe in that position; two hops is theAPPLY. So after adding one, re-run the endpoints that filter the parent byAny(...)over it — that is where a two-hop mistake surfaces.⚠️ A mirrored filter also hides the rows from the parent’s own aggregate recompute, which runs while the parent is still archived — so restoring the parent zeroes a computed total, with a 200 and no error. Any prepper summing such a dependent needs
IgnoreQueryFilters(), scoped by the parent FK:entities.patterns.md§ Aggregates over a non-owned child collection.
→ See:
entities.examples.md— DbContext
Use .For<TEntity, ...>(...) to register each entity and configure its services. The generic type arguments determine which features are enabled and which base controller to use.
Child entities configured with e.Related() don’t need their own .For<>() registration.
Before writing the controller, verify the .For<>() registration and controller pairing in entities.signatures.md. The controller must mirror the registration generics exactly.
→ See:
entities.examples.md(All entities)
Use EntityControllerBase. The generic type arguments on the controller must exactly match the type arguments used in .For<>().
The controller can add TDto and TInputDto on top. A wrong arity compiles — it only surfaces at
startup DI validation (enable ValidateOnBuild, see entities.setup.md), so copy the pairing from the
table below rather than reasoning it out.
Writing something that is not CRUD? Read the matching pattern first. A dashboard, report or export action →
entities.patterns.md§ Cross-entity aggregates & report endpoints: such a query bypasses the entity pipeline, so the row-security predicate anIGlobalFilteredQueryBuilderwould have applied is yours to repeat — omitting it is a data leak that compiles, type-checks and returns 200. A state transition (submit/approve/reject) → same file, Domain actions on an entity resource.
EntityControllerBaselives inRegira.Entities.Web.Controllers.Abstractions. A bareusing Regira.Entities.Web.Controllers;is not enough — the base class is in the.Abstractionschild namespace and the controller won’t resolve (CS0246) without it. Seeget_package(id: "Regira.Entities.Web", section: "entities.web.namespaces")for the exactusingset (this guide ships withRegira.Entities.Weband is served over MCP only — it does not extract locally).
Keep controller routes resource-relative and spell the resource —
[Route("products")]— then apply a sharedapibase once at host/app level so it stays configurable (app.UsePathBase, reverse proxy, or a global route-prefix convention). Avoid the[controller]token: it expands to the class-name stem, so aSupplierControllerserves/Supplier, not the kebab-case plural/suppliersthe SPA calls — a 404 on every request for that entity. Seeentities.setup.md— API route prefix.
.For<>() registration |
Required controller base | Inject as (outside a controller) |
|---|---|---|
.For<TEntity>() |
EntityControllerBase<TEntity, TDto, TInputDto> |
IEntityService<TEntity> |
.For<TEntity, TKey>() |
EntityControllerBase<TEntity, TKey, SearchObject<TKey>, TDto, TInputDto> |
IEntityService<TEntity, TKey, SearchObject<TKey>> |
.For<TEntity, TKey, TSearchObject>() |
EntityControllerBase<TEntity, TKey, TSearchObject, TDto, TInputDto> |
IEntityService<TEntity, TKey, TSearchObject> |
.For<TEntity, TSearchObject, TSortBy, TIncludes>() |
EntityControllerBase<TEntity, TSearchObject, TSortBy, TIncludes, TDto, TInputDto> |
IEntityService<TEntity, TSearchObject, TSortBy, TIncludes> |
.For<TEntity, TKey, TSearchObject, TSortBy, TIncludes>() |
EntityControllerBase<TEntity, TKey, TSearchObject, TSortBy, TIncludes, TDto, TInputDto> |
IEntityService<TEntity, TKey, TSearchObject, TSortBy, TIncludes> |
⚠️
.For<>()takes N type arguments; the matching controller base takes N+2 (it appends optionalTDtoandTInputDto). A wrong count compiles but fails at DI startup validation. Controllers resolveIEntityService<>fromHttpContext.RequestServicesat runtime — not via constructor injection. The “Inject as” column applies when resolving the service manually outside a controller, e.g.scope.ServiceProvider.GetRequiredService<IEntityService<...>>().
Alignment card — the three generic lists must match (register = N, controller = N+2, inject = N). Copy one tier:
// ── SIMPLE (no TSortBy/TIncludes) ──
e.For<Product, int, ProductSearchObject>(/* … */); // register (N=3)
class ProductController : EntityControllerBase<Product, int, ProductSearchObject, ProductDto, ProductInputDto>; // controller (N+2)
IEntityService<Product, int, ProductSearchObject> // inject (N)
// ── COMPLEX (typed TSortBy + TIncludes) ──
e.For<Order, int, OrderSearchObject, OrderSortBy, OrderIncludes>(/* … */); // register (N=5)
class OrderController : EntityControllerBase<Order, int, OrderSearchObject, OrderSortBy, OrderIncludes, OrderDto, OrderInputDto>; // controller (N+2)
IEntityService<Order, int, OrderSearchObject, OrderSortBy, OrderIncludes> // inject (N)
IEntityService<TEntity, TKey>is always registered by every.For<>()overload (alongside the fully-typed interface in the table). It is the safe, universal interface to inject or resolve manually when seeding or resolving outside a controller — e.g.scope.ServiceProvider.GetRequiredService<IEntityService<Supplier, int>>(). When unsure which interface a registration exposes, useIEntityService<TEntity, TKey>.Bare
IEntityService<TEntity>shortcut (noTKey) is registered only by.For<TEntity>()and the complex int.For<TEntity, TSearchObject, TSortBy, TIncludes>(). Do not rely on it for.For<TEntity, int>()or.For<TEntity, int, TSearchObject>()registrations — those exposeIEntityService<TEntity, int>(and the fully-typed interface), not the bare shortcut. Attempting to resolveIEntityService<TEntity>there throwsNo service for type IEntityService<TEntity>at runtime.
→ See:
entities.examples.md— Controllers
Endpoints provided out of the box:
| Method | Route | Action | Availability |
|---|---|---|---|
GET |
/{id} |
Details | All |
GET |
/ |
List | All |
GET |
/search |
Search (with count) | All |
POST |
/list |
List (body, batch) | Complex only ¹ |
POST |
/search |
Search (body, batch) | Complex only ¹ |
POST |
/save |
Save (upsert) | All |
POST |
/ |
Create | All |
PUT |
/{id} |
Modify — full update, all fields required | All |
PATCH |
/{id} |
Patch — partial update (JSON Merge Patch, RFC 7386) | All |
DELETE |
/{id} |
Delete — ⚠️ soft-delete on IArchivable ² |
All |
² An IArchivable entity has no hard-delete endpoint: DELETE /{id} sets IsArchived = true and the row survives, with the same 200 and the same affected count. Archived rows then disappear from GET / and GET /search, and GET /{id} 404s — so restore depends on keeping IsArchived on TInputDto (Step 5). Full route contract: entities.patterns.md → Soft Delete.
¹ The body/batch overloads (accept a TSearchObject[]) plus typed ?includes=/?sortBy= exist only on complex bases (EntityControllerBase with TSortBy + TIncludes). GET /search — single search object, returns count — is on every base, so a simple entity can page too.
Response envelope. Responses are wrapped, not bare DTOs: Details →
{ "item": {…} }; List →{ "items": [...] }; Search →{ "items": [...], "count": N }(each also carriesdurationin ms). Unwrapitem/itemsclient-side.
PATCH vs PUT: Use
PUTwhen the client has the full entity. UsePATCHwhen only a subset of fields should change. The PATCH implementation deserializes the incoming JSON Merge Patch intoTInputDto, so only fields declared on the input model can be modified.TInputDtoproperty names must matchTEntityproperty names (camelCase in JSON, PascalCase in C#, which is the STJ default). Related collections absent from the body are left intact, and a scalar field declared onTInputDtobut omitted from a PATCH body is preserved too — Merge Patch writes only the keys you send. The merged input is validated againstTInputDto’s DataAnnotations first: a failure answers 400 with model binding’sValidationProblemDetails, withouterrorDetails. The trap is the opposite case: a field the DTO never declares maps asnull/default on every PATCH and PUT — see ⚠️ below.
⚠️ A field absent from
TInputDtomaps asnull/default on PATCH and PUT. Server-owned/immutable values (OwnerIdFKs, generated codes, computed totals) silently reset — a[Required]column 500s, a computedTotalzeroes. Mark each one[ServerOwned](the Step 5 recipe; the cases needing a prepper or a primer instead are inentities.patterns.md→ Server-owned / immutable fields on update). Synced collections invert the failure: anAttachments(orRelated()) collection the DTO never declares maps asnull, which the sync reads as “not sent” — edits are ignored, not reset. Silent either way; declare the collection on the DTO (§Attachments step 3).
→ See:
entities.setup.md— P4: Create the DI Extension Method
After wiring up a new entity, always run the application — not just dotnet build.
dotnet build catches compilation errors. Startup DI validation catches mismatches between .For<>() type arguments, EntityControllerBase<> generics, and IEntityService<> injections — a wrong generic parameter throws at startup rather than silently failing on the first request.
Runtime checklist (Scalar / curl) — catches what a build cannot:
LicenseException or DI-validation throw, and logs
Regira.Entities: {n} simple / {n} complex registered → tier = free at Information
(EntityLicenseStartupLogger). Those two counts are the only confirmation that your §Step 0 tally
matches what was actually registered — check them before anything else.GET /{entity}/{id} returns a fully populated DTO — confirm every field serializes (catches field-vs-property DTO bugs) and that nested related collections are present (catches missing Includes).GET /{entity} and GET /{entity}/search return results, and any relation you eager-load appears on List/Search rows (not only on Details); on complex entities also check the batch POST /list / POST /search.POST/PUT round-trips, including any child collections synced via Related().Golden path — prove server-owned/computed fields survive a partial update (the failure a build never catches):
BASE=http://localhost:5000/orders
# 1. create — note the returned id, Code, and Total
curl -s -X POST $BASE -H 'Content-Type: application/json' \
-d '{"customerId":"<guid>","orderLines":[{"productId":1,"quantity":2}]}'
# 2. PATCH only status — send nothing else
curl -s -X PATCH $BASE/1 -H 'Content-Type: application/json' -d '{"status":"Shipped"}'
# 3. re-read
curl -s $BASE/1
Expected: Code and Total are unchanged from step 1 (not null/0) and status is now Shipped — if either reset, a server-owned field is missing its [ServerOwned] declaration (Step 5). Verify seeded data through the API (GET /{entity}/search), never by the .db file size.
Assert your seed data’s domain invariants with a query, not by eyeballing a page. Name each rule the data must satisfy (“every asset whose status is In use has a holder”; “every event has at least one session”), then prove it with a search that must return count: 0 — GET /assets/search?statusId=3&isAssigned=false. This is the one class of bug a green build, a green type-check and a passing round-trip all miss: the generator loop that skips a case leaves data that is individually valid and collectively wrong, and it only shows up as something looking odd on a page nobody scrolled to.
Then check the distributions, not only the invariants. An invariant catches the rule you thought of; a ratio catches the one you didn’t. Count each state your UI visualises — a bucket sitting at 0 % or 100 % of its population is a generator bug, however plausible each individual row is. The classic shape is a date derived from a uniformly-spread Created against a much shorter SLA or due window: every row is defensible and every open item is overdue, which makes the badge, the filter and the dashboard tile meaningless. Derive such dates relative to each row’s own window instead, and re-count.
Finally, read twenty whole rows — GET /{entity}/search?pageSize=20, every column. A ratio proves a bucket is populated, never that a row makes sense: columns drawn from independent pools are each well-distributed and jointly absurd (“Lenovo MacBook Pro”), and a date can sit inside a healthy 41 % bucket and still be seven years stale. Draw correlated columns as tuples. Neither defect is reachable by counting, and both are obvious on sight.
Rows scoped to a user/tenant, or endpoints gated by role? One more check applies — §Security & Authorization → Verify per identity.
Seed through the services, not the DbContext (no controller, so the usual gotchas are yours to handle):
Three traps: name the token —
List(null, token: token)— or it binds as the search object and filters nothing;SaveChanges()clears the change tracker (saved entities detach);e.Related()owned/join rows have no service of their own.
builder.Build() and app.Run(). app.Run() blocks until shutdown, so seeding code placed after it never executes — and the app looks empty with no error anywhere..For<>() registers IEntityService<TEntity, TKey> — resolve that shape (e.g. IEntityService<Product, int>) in a scope for seeding/jobs, whatever the builder overload.List/Count take object? so first — a positionally-passed CancellationToken binds as the search object and silently filters nothing. Name the token: List(null, token: token).await service.SaveChanges() yourself.await service.Add(item) (⚠️ preppers run per item, so a DB-touching prepper makes the loop N+1 — batch its lookups), then SaveChanges() once — see entities.patterns.md → Bulk insert / update. Standard EF auto-increment rules apply, so flush a parent batch before assigning child.ParentId = parent.Id.SaveChanges() clears the change tracker (unlike stock EF Core) — saved entities detach. To touch an earlier wave again, re-Modify() it first.db.Invoices.AsNoTracking().ToListAsync(), then Modify each. Details(id) is the wrong reader here: it applies every registered Includes(...), so each row arrives carrying its own copy of a shared principal, and attaching the second one throws “The instance of entity type ‘X’ cannot be tracked because another instance with the same key value is already being tracked.” This is the natural last wave of any seed that has a derived aggregate to settle (§ Aggregates over a non-owned child collection), so it is where the crash lands.item.Shopper = shopper re-adds that instance as a new graph member and the wave dies on UNIQUE constraint failed: Shoppers.Id. Read the keys you need (.AsNoTracking(), select the id) and assign item.ShopperId = id. Reserve navigation objects for children genuinely created in the same wave.e.Related() (m2m join, hierarchy join) can’t be seeded through the service loop. Seed it through the owning parent’s navigation, or via the DbContext for a standalone join — see entities.patterns.md → Seeding owned / join rows.AnyAsync(...) dedupe check passes and the wave then dies on a UNIQUE constraint at SaveChanges(). Dedupe a wave with a HashSet of keys instead.Count() cannot see queued rows either, and DbSet.Local with an Id == 0 filter miscounts once earlier flushes assigned ids. Count pending rows with dbContext.ChangeTracker.Entries<T>().Count(e => e.State == EntityState.Added) plus the persisted count.SaveChanges() (e.g. EntityConstraintException) leaves every entry tracked — stock EF Core semantics — so you can fix or remove the offending entity and retry the same call; entities from the failed wave are not silently dropped. Only a successful save clears the tracker (the deliberate deviation from stock EF Core).IEntityService; override only what you neede.UseEntityService<MyCustomEntityService>() to replace the default repositoryEntityServiceSave(item) routes to this service’s own Add/Modify (based on IEntity.IsNew()), so put create/update wrapping logic in Add/Modify — the controller write path calls Save(), not Add() directlyWho flushes what, when. Add/Modify/Save only track changes; the controller flushes once, calling service.Save(item) then service.SaveChanges() (PUT, PATCH and POST all end in that same pair). Inside an override:
base.Modify(item) returns the detached pre-modification original (the write service detaches it to attach item in its place). Read it for old values; mutating it persists nothing. Mutate item instead — it is the tracked instance.Details, List) is no-tracking, so “load a sibling via the service, change it, rely on the controller’s flush” silently persists nothing. Query the injected DbContext directly (tracked), or call await SaveChanges() yourself after staging the extra change.SaveChanges() flushes and clears the tracker (success only) — anything staged after a flush needs its own flush.Examples:
EntityRepository with IMemoryCache.
Details(id) — check the cache first, then call base.Details(id) and store the resultSave(item) (and Remove(item) if needed) — call base, then invalidate the cache entryRegistration:
e.AddTransient<IProductService, ProductService>() — enables typed injection by interfacee.UseEntityService<ProductService>() — replaces the default EntityRepository as IEntityService for the entity→ See:
entities.signatures.md— EntityWrappingServiceBase → See:entities.examples.md— Order + OrderLine entities (OrderManager)
To give every entity the same repository behaviour (auditing, caching, a logging hook), derive generic
classes from EntityRepository and register them once with UseRepository(). Every For<>() that names no
repository of its own then uses them, as IEntityRepository<…> and as IEntityService<…>; e.HasRepository<T>()
and e.UseEntityService<T>() still win per entity.
A type is matched to a For<>() by its number of type parameters, mirroring the EntityRepository it
derives from — so write one class per shape the app registers:
For<>() shape |
Derive from |
|---|---|
For<TEntity>() (int key) |
EntityRepository<TEntity>, or the 2-parameter class closed over int plus IEntityRepository<TEntity> (below) |
For<TEntity, TKey>() |
EntityRepository<TEntity, TKey> |
For<TEntity, TKey, TSearchObject>(), WithAttachments() |
EntityRepository<TEntity, TKey, TSearchObject> |
For<TEntity, TSearchObject, TSortBy, TIncludes>() |
EntityRepository<TEntity, TSearchObject, TSortBy, TIncludes> |
For<TEntity, TKey, TSearchObject, TSortBy, TIncludes>() |
EntityRepository<TEntity, TKey, TSearchObject, TSortBy, TIncludes> |
using Microsoft.Extensions.Logging;
using Regira.Entities.EFcore.Services;
using Regira.Entities.Models;
using Regira.Entities.Models.Abstractions;
using Regira.Entities.Services.Abstractions;
public class AppRepository<TEntity, TKey>(
IEntityReadService<TEntity, TKey, SearchObject<TKey>> readService,
IEntityWriteService<TEntity, TKey> writeService,
ILogger<AppRepository<TEntity, TKey>> logger)
: EntityRepository<TEntity, TKey>(readService, writeService)
where TEntity : class, IEntity<TKey>
{
public override async Task Remove(TEntity item, CancellationToken token = default)
{
logger.LogInformation("Removing {Entity} {Id}", typeof(TEntity).Name, item.Id);
await base.Remove(item, token);
}
}
// The int-key shape most For<TEntity>() registrations use: the 2-parameter class closed over int
public class AppRepository<TEntity>(
IEntityReadService<TEntity, int, SearchObject<int>> readService,
IEntityWriteService<TEntity, int> writeService,
ILogger<AppRepository<TEntity, int>> logger)
: AppRepository<TEntity, int>(readService, writeService, logger), IEntityRepository<TEntity>
where TEntity : class, IEntity<int>;
The complex shapes follow the same pattern. Their read service is the complex one, carrying the sort and
includes enums, and the int-key shape is again the full class closed over int:
using Microsoft.Extensions.Logging;
using Regira.Entities.EFcore.Services;
using Regira.Entities.Models.Abstractions;
using Regira.Entities.Services.Abstractions;
public class AppRepository<TEntity, TKey, TSearchObject, TSortBy, TIncludes>(
IEntityReadService<TEntity, TKey, TSearchObject, TSortBy, TIncludes> readService,
IEntityWriteService<TEntity, TKey> writeService,
ILogger<AppRepository<TEntity, TKey, TSearchObject, TSortBy, TIncludes>> logger)
: EntityRepository<TEntity, TKey, TSearchObject, TSortBy, TIncludes>(readService, writeService)
where TEntity : class, IEntity<TKey>
where TSearchObject : class, ISearchObject<TKey>, new()
where TSortBy : struct, Enum
where TIncludes : struct, Enum
{
public override async Task Remove(TEntity item, CancellationToken token = default)
{
logger.LogInformation("Removing {Entity} {Id}", typeof(TEntity).Name, item.Id);
await base.Remove(item, token);
}
}
// For<TEntity, TSearchObject, TSortBy, TIncludes>(): the 5-parameter class closed over int
public class AppRepository<TEntity, TSearchObject, TSortBy, TIncludes>(
IEntityReadService<TEntity, int, TSearchObject, TSortBy, TIncludes> readService,
IEntityWriteService<TEntity, int> writeService,
ILogger<AppRepository<TEntity, int, TSearchObject, TSortBy, TIncludes>> logger)
: AppRepository<TEntity, int, TSearchObject, TSortBy, TIncludes>(readService, writeService, logger),
IEntityRepository<TEntity, TSearchObject, TSortBy, TIncludes>
where TEntity : class, IEntity<int>
where TSearchObject : class, ISearchObject<int>, new()
where TSortBy : struct, Enum
where TIncludes : struct, Enum;
A complex class serves only its own shape, not the simple ones, so an app with both kinds registers both.
services.UseEntities<AppDbContext>(o =>
{
o.UseDefaults();
o.UseRepository(typeof(AppRepository<>), typeof(AppRepository<,>),
typeof(AppRepository<,,,>), typeof(AppRepository<,,,,>));
})
.For<Product>() // AppRepository<Product>
.For<Category, Guid>() // AppRepository<Category, Guid>
.For<Invoice, InvoiceSearchObject, InvoiceSortBy, InvoiceIncludes>() // AppRepository<Invoice, InvoiceSearchObject, …>
.For<Order>(e => e.HasRepository<OrderRepository>()); // its own repository wins
typeof(AppRepository<,>)); a closed type, an abstract one or one that implements
no IEntityRepository throws ArgumentException at the call.where TEntity : IArchivable),
or a missing interface of the EntityRepository it replaces, throws InvalidOperationException at that
For<>(), naming the entity. Give such an entity its own repository with e.HasRepository<T>().For<>() whose shape has no type keeps the default EntityRepository; startup validation warns, naming
the entities. Keep one on the default deliberately with e.HasRepository<EntityRepository<…>>().UseRepository() applies to the For<>() calls after it, across every UseEntities() call on the service collection.IArchivable entity follows the same rule as HasRepository<>(): override both
Details overloads (entities.patterns.md → Soft Delete).A concern that has to see every call — logging, timing, auditing — overrides every member below. Each member hands
its call straight to the read or write service, so one call reaches one member, with one exception:
Details(id, archived) returns Details(id) when the filter changes nothing — no filter, or an entity that is not
IArchivable. Save goes to the write service, which decides between insert and update itself, so it never passes
through the repository’s Add or Modify.
| Members | Shapes | Called by |
|---|---|---|
Add, Modify, Save, Remove, SaveChanges |
every shape | writes; the generated controllers call Save and Remove |
Details(id), Details(id, archived) |
every shape | a details read; the controllers pass the archived filter |
List(TSearchObject?, PagingInfo?), Count(TSearchObject?) |
every shape | a typed search |
List(object?, PagingInfo?), Count(object?) |
every shape | a search by an anonymous object: the controllers’ save check and delete, the attachment routes |
List(IList<TSearchObject?>, IList<TSortBy>, TIncludes?, PagingInfo?), Count(IList<TSearchObject?>) |
complex | the complex search endpoint |
Keep the logic in one class and let each shape’s repository call it, as below. The complex EntityRepository does
not derive from the simple one, so its class overrides every member again, the two IList overloads included.
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Regira.DAL.Paging;
using Regira.Entities.EFcore.Services;
using Regira.Entities.Models;
using Regira.Entities.Models.Abstractions;
using Regira.Entities.Services.Abstractions;
// the shared logic: one place for every repository shape
public static class RepositoryLog
{
public static async Task<T> Timed<T>(this ILogger logger, string operation, Type entityType, Func<Task<T>> call)
{
var watch = Stopwatch.StartNew();
try
{
return await call();
}
finally
{
logger.LogInformation("{Operation} {Entity} in {Elapsed} ms", operation, entityType.Name, watch.ElapsedMilliseconds);
}
}
public static Task Timed(this ILogger logger, string operation, Type entityType, Func<Task> call)
=> logger.Timed(operation, entityType, async () => { await call(); return true; });
}
public class LoggingRepository<TEntity, TKey>(
IEntityReadService<TEntity, TKey, SearchObject<TKey>> readService,
IEntityWriteService<TEntity, TKey> writeService,
ILogger<LoggingRepository<TEntity, TKey>> logger)
: EntityRepository<TEntity, TKey>(readService, writeService)
where TEntity : class, IEntity<TKey>
{
private static readonly Type Entity = typeof(TEntity);
// Details(id, archived) hands over to Details(id) unless the filter applies, so it logs only when it does not
private static readonly bool IsArchivable = typeof(IArchivable).IsAssignableFrom(typeof(TEntity));
public override Task<TEntity?> Details(TKey id, CancellationToken token = default)
=> logger.Timed("Details", Entity, () => base.Details(id, token));
public override Task<TEntity?> Details(TKey id, ArchivedFilter? archived, CancellationToken token = default)
=> archived.HasValue && IsArchivable
? logger.Timed("Details", Entity, () => base.Details(id, archived, token))
: base.Details(id, archived, token);
public override Task<IList<TEntity>> List(SearchObject<TKey>? so = null, PagingInfo? pagingInfo = null, CancellationToken token = default)
=> logger.Timed("List", Entity, () => base.List(so, pagingInfo, token));
public override Task<long> Count(SearchObject<TKey>? so, CancellationToken token = default)
=> logger.Timed("Count", Entity, () => base.Count(so, token));
public override Task<IList<TEntity>> List(object? so, PagingInfo? pagingInfo, CancellationToken token = default)
=> logger.Timed("List", Entity, () => base.List(so, pagingInfo, token));
public override Task<long> Count(object? so, CancellationToken token = default)
=> logger.Timed("Count", Entity, () => base.Count(so, token));
public override Task Add(TEntity item, CancellationToken token = default)
=> logger.Timed("Add", Entity, () => base.Add(item, token));
public override Task<TEntity?> Modify(TEntity item, CancellationToken token = default)
=> logger.Timed("Modify", Entity, () => base.Modify(item, token));
public override Task Save(TEntity item, CancellationToken token = default)
=> logger.Timed("Save", Entity, () => base.Save(item, token));
public override Task Remove(TEntity item, CancellationToken token = default)
=> logger.Timed("Remove", Entity, () => base.Remove(item, token));
public override Task<int> SaveChanges(CancellationToken token = default)
=> logger.Timed("SaveChanges", Entity, () => base.SaveChanges(token));
}
public class LoggingRepository<TEntity, TKey, TSearchObject, TSortBy, TIncludes>(
IEntityReadService<TEntity, TKey, TSearchObject, TSortBy, TIncludes> readService,
IEntityWriteService<TEntity, TKey> writeService,
ILogger<LoggingRepository<TEntity, TKey, TSearchObject, TSortBy, TIncludes>> logger)
: EntityRepository<TEntity, TKey, TSearchObject, TSortBy, TIncludes>(readService, writeService)
where TEntity : class, IEntity<TKey>
where TSearchObject : class, ISearchObject<TKey>, new()
where TSortBy : struct, Enum
where TIncludes : struct, Enum
{
private static readonly Type Entity = typeof(TEntity);
private static readonly bool IsArchivable = typeof(IArchivable).IsAssignableFrom(typeof(TEntity));
public override Task<TEntity?> Details(TKey id, CancellationToken token = default)
=> logger.Timed("Details", Entity, () => base.Details(id, token));
public override Task<TEntity?> Details(TKey id, ArchivedFilter? archived, CancellationToken token = default)
=> archived.HasValue && IsArchivable
? logger.Timed("Details", Entity, () => base.Details(id, archived, token))
: base.Details(id, archived, token);
public override Task<IList<TEntity>> List(TSearchObject? so = null, PagingInfo? pagingInfo = null, CancellationToken token = default)
=> logger.Timed("List", Entity, () => base.List(so, pagingInfo, token));
public override Task<long> Count(TSearchObject? so, CancellationToken token = default)
=> logger.Timed("Count", Entity, () => base.Count(so, token));
public override Task<IList<TEntity>> List(object? so, PagingInfo? pagingInfo, CancellationToken token = default)
=> logger.Timed("List", Entity, () => base.List(so, pagingInfo, token));
public override Task<long> Count(object? so, CancellationToken token = default)
=> logger.Timed("Count", Entity, () => base.Count(so, token));
// the search endpoint's: several search objects, sorting and includes
public override Task<IList<TEntity>> List(IList<TSearchObject?> so, IList<TSortBy> sortBy, TIncludes? includes = null, PagingInfo? pagingInfo = null, CancellationToken token = default)
=> logger.Timed("List", Entity, () => base.List(so, sortBy, includes, pagingInfo, token));
public override Task<long> Count(IList<TSearchObject?> so, CancellationToken token = default)
=> logger.Timed("Count", Entity, () => base.Count(so, token));
public override Task Add(TEntity item, CancellationToken token = default)
=> logger.Timed("Add", Entity, () => base.Add(item, token));
public override Task<TEntity?> Modify(TEntity item, CancellationToken token = default)
=> logger.Timed("Modify", Entity, () => base.Modify(item, token));
public override Task Save(TEntity item, CancellationToken token = default)
=> logger.Timed("Save", Entity, () => base.Save(item, token));
public override Task Remove(TEntity item, CancellationToken token = default)
=> logger.Timed("Remove", Entity, () => base.Remove(item, token));
public override Task<int> SaveChanges(CancellationToken token = default)
=> logger.Timed("SaveChanges", Entity, () => base.SaveChanges(token));
}
The other shapes are thin: the 1-parameter class is the 2-parameter one closed over int, and the 4-parameter
class the 5-parameter one, as in the classes above. An app with a For<TEntity, TKey, TSearchObject>() writes the
same overrides on EntityRepository<TEntity, TKey, TSearchObject> and can derive the 2-parameter class from that
one, closed over SearchObject<TKey>.
EntityServiceCollectionOptions (inside UseEntities())entities.examples.md)options.UseDefaults() is a convenience method that registers, in one call:
DefaultPageSize = 10, MaxPageSize = 100 (override either afterwards). An omitted pageSize uses the default; a pageSize <= 0 opts out and falls back to the max; every request is capped by MaxPageSize.Regira.Utilities.DateTimeDefaults.UseUtc): timestamps are written as UTC, request-body DateTimes are read as UTC by ConfigureDefaultJsonOptions() (a local offset is converted, an offset-less value is taken as UTC — so a prepper compares them with original on one clock), and the built-in Created/LastModified filters normalize their inputs. A DateTime on your own SearchObject is bound as sent: call .AsUtc() (Regira.Utilities) on it in your filter. Disable with e.UseUtc(false) → values are used as given with DateTime.Now timestamps, and the UTC convention’s converter goes inert automatically.HasCreatedDbPrimer, HasLastModifiedDbPrimer, ArchivablePrimer, HasConcurrencyTokenDbPrimer (timestamps, soft-delete stamping, concurrency-token minting).AddDefaultInterceptors()) — UseEntities<TContext>() contributes the primer/normalizer/auto-truncate/reactor interceptors, the UTC date convention, the archived query filter and the concurrency-token convention to the context’s options itself, so AddDbContext only needs the provider and the DbContext needs no Regira call. Matches by assignability: an abstract-base registration (UseEntities<AppContextBase>()) also wires derived provider-specific contexts, in any registration order. Fine-grained control via e.WireDbContext(DbContextWiring …): None opts out; without UseDefaults() use e.AddDefaultInterceptors() for the full set or pick pieces à la carte (e.g. DbContextWiring.PrimerInterceptors).FilterIdsQueryBuilder, FilterArchivablesQueryBuilder, FilterHasCreatedQueryBuilder, FilterHasLastModifiedQueryBuilder. These are int-keyed; for full key-typed filtering of a non-int entity also call AddDefaultGlobalQueryFilters<TKey>(). The query builder runs one variant per filter family and prefers the key-matching one, so a non-int entity’s key-agnostic defaults (the archived opt-ins, timestamp/Q filtering) still apply when only the int variant is registered.Hiding archived rows is part of
UseDefaults()(DbContextWiring.ArchivedQueryFilter): thee => !e.IsArchivedEF query filter is wired into the context’s options, so archived rows are hidden on every list/count and inside included collections without a line in theDbContext. The registered query builder translates the opt-ins on top of it — a caller opts in per request with?archived=included(both) or?archived=only(the recycle bin); flip the app-wide default withDefaultArchivedFilter = ArchivedFilter.IncludedonUseEntities(). ⚠️ The wiring reaches contexts resolved from DI only: a hand-constructednew AppDbContext(options)takes.AddArchivedQueryFilter()on its options builder. Startup validation errors out naming the entity when a model ends up without the filter. Full round-trip:entities.patterns.md→ Soft Delete.
- Default entity normalizer —
DefaultEntityNormalizer(processes[Normalized]).- The normalized-content
Qfilter —FilterHasNormalizedContentQueryBuilder— but only on the parameterless path.UseDefaults()registers it;UseDefaults(cfg => …)(passing a normalizing configure callback) does not, so register it yourself in that case.
→ See: §Quick Reference: Built-in Services (this file) for the full list of registered classes.
UseEntities() registers a hosted service that validates the entity registrations once at host start —
in the Development environment by default. It catches, with actionable messages:
For<>() arity mismatches — an EntityControllerBase<…> subclass whose generic
arguments match no registered IEntityService<…> fails startup listing the registered alternatives
(enabled by ConfigureDefaultJsonOptions() or ValidateEntityControllers()), plus a missing IEntityMapper.DbContext options lack
the matching interceptor (they would silently never run). Only applies to setups without UseDefaults()
(which auto-wires the interceptors) that also skipped e.WireDbContext(...).?q= (warning) — entities without IHasNormalizedContent and without a custom filter.Related() that also has its own .For<>().
Supported when the parent’s input DTO omits the collection; the validator can’t see DTO shapes, so it always
reports the pairing. Detects top-level Related() calls, not ones nested inside a configure builder.IHasAttachments entity whose input DTO (its
UseMapping one, or else its entity controller’s TInputDto) declares no Attachments collection. Every parent write then maps the collection to null
(“not sent”), so attachment adds/removes/reorders through the entity controller are silently ignored
(§Attachments step 3).Uri (warning) — an attachment controller is mapped while the null resolver is in
place, i.e. UseAttachmentUris() was omitted or set on a different options instance.ObjectId (warning) — an IHasAttachments owner whose attachment
collection reaches the link through any key but ObjectId, typically a shadow key EF invented because the
owner side was left to conventions. Every link row is saved orphaned: the owner’s Attachments loads empty and
?hasAttachment=true matches nothing (§Attachments step 5).IArchivable principal that
separately registered entities reference through a required FK. Archiving such a row drops the dependents
from list results while /search keeps counting them; the message works the mirrored-filter remedy
(entities.patterns.md → Soft Delete).TEntity no registered entity
satisfies, so it never runs. Usually a scope that names an interface the entity does not implement; when the
filter guards row access, the rows it was meant to hide are being returned. The built-in defaults are
reported as information instead: UseDefaults() registers the whole set whether or not the app has an
IArchivable (or timestamped, or normalized-content) entity, so an inert one carries no signal.TScope no registered entity, nor a
type deriving from one, is, derives from or implements, so it never runs; typically one scoped to a Related()
child, which is validated through its parent (§Step 8 → Validators). A validator on Person with only
For<Party>() is not reported: a Person saved through the Party service runs it.IEnumerable<IEntityValidator>: a write service on
EntityWriteService’s constructor without validators, or a service over another store. Services registered
through a factory are skipped. A validator scoped wider than what it checks implements ISelectiveEntityValidator
and counts, here and when a write runs, only for the entities it Covers — FluentValidation’s, for those an
AbstractValidator applies to.UseRepository() replaced the default repository, but no
type has the number of type parameters some For<>() registrations need, so those entities keep the default
EntityRepository (§Custom Entity Services → Replacing the repository app-wide).IArchivable entity whose model carries no archived
filter, from either route: the options wiring (DbContextWiring.ArchivedQueryFilter, on by default) or an
explicit modelBuilder.SetArchivedQueryFilter(). DELETE flags those rows and nothing hides them. Reached
by a context the wiring misses — a non-generic UseEntities(), or WireDbContext(...) without the flag.
Suppress it deliberately with o.DefaultArchivedFilter = ArchivedFilter.Included if archived rows are meant
to stay visible. It inspects contexts resolved from DI, so a DbContext constructed by hand is outside its
reach as well as the wiring’s.Configure via UseEntities(o => o.ConfigureValidation(v => { v.Enabled = true; v.ThrowOnError = false; })):
Enabled — null (default) = Development only, true = always (Production opt-in), false = never.
Diagnostic code REGIRA0001 marks the obsolete EntityPrimerContainer(DbContext, IServiceCollection) constructor, which builds a second service provider to resolve primers — use RegisterPrimerContainer<TContext>() (the IEnumerable<IEntityPrimer> constructor) instead.
PagingInfo.PageSize is nullable, so List/Search distinguish three cases (enforced at the HTTP boundary; UseDefaults() sets DefaultPageSize = 10, MaxPageSize = 100):
Requested pageSize |
Effective page size |
|---|---|
omitted (null) |
DefaultPageSize (or the max when no default is configured) |
0 or negative |
MaxPageSize — opts out of paging, capped at the max |
positive n |
n, clamped to MaxPageSize |
EntityControllerBase List/Search apply one shared rule (EntityListOptionsExtensions.ApplyPagingDefaults), which any other HTTP surface can reuse so MaxPageSize cannot be escaped. Direct IEntityService calls are unaffected — they apply the PagingInfo you pass as-is (PageSize null or <= 0 → everything, uncapped), so the service layer keeps full control.MaxPageSize is always the ceiling; null for either option turns that aspect off.Global — inside UseEntities():
services.UseEntities<AppDbContext>(options =>
{
options.DefaultPageSize = 50; // applied when the request omits pageSize
options.MaxPageSize = 200; // caps every request; also what a pageSize <= 0 opt-out returns
});
Per-entity override — inside .For<>() (fully replaces the global values for that entity):
.For<Product>(e => e.SetPageSize(defaultPageSize: 25, maxPageSize: 100))
.For<LogEntry>(e => e.SetPageSize()); // opt out entirely: omitted / pageSize <= 0 returns every row
DefaultNormalizer.Normalize(string?), in order. The query side runs the same transform — QKeywordHelper.ApplyNormalize defaults to true — so a stored value matches ?q= only if both sides survive it identically.
| # | Rule | Café A.C.M.E. #1 → |
|---|---|---|
| 1 | diacritics removed (RemoveDiacritics = true) |
Cafe A.C.M.E. #1 |
| 2 | anything outside a-z A-Z 0-9, whitespace and - _ , ! ; & ' is deleted, not replaced by a space |
Cafe ACME 1 |
| 3 | - , ! ; & ' → space (_ is kept as-is) |
Cafe ACME 1 |
| 4 | whitespace collapsed and trimmed | Cafe ACME 1 |
| 5 | Transform — default NoChanges, so case is preserved, never uppercased |
Cafe ACME 1 |
⚠️ Rule 2 is why raw text in NormalizedContent can never be found. A.C.M.E. stored raw stays A.C.M.E., while ?q=ACME and ?q=A.C.M.E. both normalize to ACME — no match, no error. Always write NormalizedContent through [Normalized] or an injected INormalizer; never by string concatenation.
⚠️ The one that populates NormalizedContent goes on the property. The attribute is legal on a class
too, but the two placements take different options, and only the property form has a SourceProperty /
SourceProperties to normalize from. Put [Normalized(SourceProperties = [...])] on the class and it
compiles, binds nothing, and leaves NormalizedContent null for every row — so ?q= matches nothing and
every search returns empty, at HTTP 200, with no error and no warning.
public class Category : IEntity<int>, IHasNormalizedContent
{
public string Title { get; set; } = null!;
public string? Description { get; set; }
[MaxLength(1024), Normalized(SourceProperties = [nameof(Title), nameof(Description)])]
public string? NormalizedContent { get; set; }
}
Pick short, identifying sources — not body text. NormalizedContent is a [MaxLength(1024)] search key,
not a copy of the record: feed it titles, codes, names and short summaries. A description or free-text body
overflows it, and the normalizing primer then truncates on every save with a
Truncating X.NormalizedContent from N to 1024 characters warning — harmless but noisy, and the tail that
falls off is silently unsearchable. Widen [MaxLength] if you genuinely need more.
[Normalized] attribute options:
| Property | Valid on | Purpose |
|---|---|---|
SourceProperty |
property | Single source property name |
SourceProperties |
property | Array of source property names (concatenated with space) |
Recursive |
class | Walk nested objects as well (default: true) |
Normalizer |
either | Custom INormalizer (property) or IObjectNormalizer (class) type |
→ See:
entities.examples.md— Category entity
e.AddNormalizer<ProductNormalizer>()options.AddNormalizer<IHasPhone, PhoneNormalizer>() — TEntity may be an interface of your own (IHasPhone here), reaching every entity that implements itIsExclusive = true, no other normalizer runs for that entity→ See:
entities.examples.md— Additional Patterns > Global normalizer
Use IQKeywordHelper.Parse(q) to parse Q into keywords with wildcard support (e.g. "blue*" → "blue%"). For normalized columns, use keyword.QW with EF.Functions.Like (for raw columns, use keyword.TrimmedQW). Use Like in a hand-written text filter too (matching a related entity’s content, say): EF translates string.Contains to SQLite’s case-sensitive instr(), so ?q=emma finds nothing while ?q=Emma works — inconsistent with every built-in filter, and silent.
⚠️ Match the keyword family to the column. Every
QKeywordcarries the term twice: theTrimmed*members (Trimmed,TrimmedStartsWith,TrimmedEndsWith,TrimmedQ,TrimmedQW) hold the raw input, everything without that prefix (Normalized,StartsWith,EndsWith,Q,QW) holds the normalized one. Use the unprefixed members against a normalized column —NormalizedContent,NormalizedLastName— and theTrimmed*ones against a column that stores the client’s value verbatim, such as an attachmentFileNameor a reference code. Crossing them compiles and returns nothing, with no error: the default normalizer drops.and turns-into a space, and preserves case —Transformdefaults toNoChanges— somy-report.pdfis looked up asmy reportpdf.⚠️ The
Trimmed*members hold the raw input, so a%or_the client typed keeps its SQLLIKEmeaning. On the attachments endpoint — theTrimmed*caller —?fileName=my_report*also matchesmy-report.pdf, and?fileName=50%*matches every name starting50. That over-matches, never under-matches, and is not injection — the term still travels as a parameter — but escape%and_before building the pattern if exact punctuation has to match.
?q=is narrower, not exempt. On theIHasNormalizedContentpath the term reachesQWthrough the normalizer, whose allow-list ([^a-z0-9\s\-_,!;&']) deletes a typed%— but_is in that set and survives, so?q=my_reportstill searches%my_report%and the underscore keeps its single-characterLIKEmeaning. On the explicit-fieldsFilterQpath nothing is deleted: it matchesTrimmedQW, so both%and_arrive intact, exactly as on the attachments endpoint.
Searching a few explicit columns — for an entity that is not IHasNormalizedContent, FilterQ takes the field selectors and builds the predicate (each keyword must match at least one field, so "acme 2024" matches a row whose code carries one term and whose related shopper carries the other). Without this or a custom filter, ?q= is silently ignored — startup says so.
This overload matches the raw family (TrimmedQW), because the columns you name here hold the client’s value verbatim — a code, a name. ?q=ORD-2024-001 therefore searches %ORD-2024-001% and finds the stored ORD-2024-001. The one thing not to pass it is a normalized column (NormalizedLastName and the like): build that predicate with QW yourself, per the callout above.
IQKeywordHelper is injected, so this is a registered builder class, not an inline e.Filter(...) lambda (a lambda has no DI — see §Step 6):
public class OrderQueryBuilder(IQKeywordHelper qHelper) : FilteredQueryBuilderBase<Order, int, OrderSearchObject>
{
public override IQueryable<Order> Build(IQueryable<Order> query, OrderSearchObject? so)
=> query.FilterQ(qHelper.Parse(so?.Q), x => x.Code, x => x.Shopper!.Name);
}
// registration
e.AddFilter<OrderQueryBuilder>();
Or use the built-in global filter (applies to all IHasNormalizedContent entities):
options.AddGlobalFilterQueryBuilder<FilterHasNormalizedContentQueryBuilder>();
⚠️ For cross-field/cross-relation
Qsearch, fold the related data intoNormalizedContent— don’t add a secondQfilter. The global normalized-content filter already AND-s aQpredicate for everyIHasNormalizedContententity, so a second, customQfilter is AND-ed on top of it and silently narrows (or empties) the result. The idiomatic fix is to make the parent’sNormalizedContentalready contain the searchable text from the related entities — register a customEntityNormalizer(or use[Normalized(SourceProperties = [...])]) that includes those properties — so the single global filter covers everything.
- Keep it fresh:
NormalizedContentis computed when the parent is saved, so re-normalize the parent whenever the related data it embeds changes (e.g. via a prepper/primer, or by re-saving the parent).- ⚠️ Same-save timing: if the normalizer queries related rows from the database, standard EF rules apply — children added in the same
SaveChangesaren’t committed yet, so the normalizer won’t see them. Reading the parent’s in-memory navigation collection instead avoids this.- Live-join fallback: only when you genuinely need a live join rather than denormalized content should you write your own
Qfilter — and then don’t implementIHasNormalizedContenton that parent (so the global filter doesn’t apply) and OR the conditions yourself, otherwise the two predicates AND.
→ See:
entities.examples.md— Additional Patterns > IQKeywordHelper — Q full-text search
Normalizers run automatically when saving: UseDefaults() wires the normalizer interceptor into the
DbContext options; without UseDefaults(), select e.WireDbContext(DbContextWiring.NormalizerInterceptors).
→ See:
entities.patterns.md— DbContext Interceptors — Quick Reference
→ See:
entities.examples.md— Additional Patterns > Attachments → See:entities.signatures.md— Attachments → See:io.storage.instructions— theIFileServicefile-store contract behind attachments (read before wiring a store).get_package(id: "Regira.IO.Storage", section: "io.storage.instructions"), orio.storage.instructions.mdlocally.
EntityAttachment base and set ObjectType in the constructor:
public class ProductAttachment : EntityAttachment { public ProductAttachment() => ObjectType = nameof(Product); }.
One subclass per owner entity — the class is the join table, and its constructor pins a single
ObjectType, so a second owner needs its own subclass, DbSet, controller and registration. Budget it as
one extra simple slot per owner, not one for the whole app.IHasAttachments and IHasAttachments<TAttachment> on the owning entity. The typed collection
is the ordinary property; the non-generic one takes the explicit implementation, casting both ways.
Both interfaces also require HasAttachment — a flag, not a column (on an entity that already maps it, the
next migration drops that column); the ⚠️ note below says who fills it:
[NotMapped] public bool? HasAttachment { get; set; } // System.ComponentModel.DataAnnotations.Schema
public ICollection<ProductAttachment>? Attachments { get; set; }
ICollection<IEntityAttachment>? IHasAttachments.Attachments
{
get => Attachments?.Cast<IEntityAttachment>().ToArray();
set => Attachments = value?.Cast<ProductAttachment>().ToArray();
}
UseMapping)? Declare the collection on it: public ICollection<EntityAttachmentInputDto>? Attachments { get; set; } (or your derived attachment input DTO). Without it the convention map drops the incoming collection on every save and the sync reads that as “attachments not sent” — adds, removes and reorders through the parent are silently ignored (200 OK, no error; the /{objectId}/attachments sub-routes still work, which masks it). Startup validation warns. Mirror on the read DTO with ICollection<EntityAttachmentDto>?.EntityAttachmentControllerBase<TAttachment> — name it after the attachment type (ProductAttachmentController or ProductAttachmentsController for a ProductAttachment; any other name makes Uri unresolvable, see 7) and set the class route to the owner base path, e.g. [Route("products")] (resource-relative — see the route-prefix note in §Step 13). The base controller appends the sub-routes {objectId}/attachments, attachments/{id}, {objectId}/files, ….DbSet<Attachment> and DbSet<TAttachment> to the DbContext and map both relationships in
OnModelCreating. ⚠️ The owner side is the one EF cannot infer — ObjectId is not a conventional FK name, so
left out it gets a shadow ProductId, every link row is saved orphaned, the owner’s Attachments loads empty
and ?hasAttachment=true matches nothing (the /{id}/attachments sub-routes still list the files; startup
validation warns):
modelBuilder.Entity<ProductAttachment>().HasOne(x => x.Attachment).WithMany().HasForeignKey(x => x.AttachmentId);
modelBuilder.Entity<Product>().HasMany(x => x.Attachments).WithOne().HasForeignKey(x => x.ObjectId).HasPrincipalKey(x => x.Id);
.WithAttachments(_ => new BinaryFileService(...)) for the shared Attachment entity + file store + bytes→file primer + AttachmentFileReactor (removes a replaced or deleted attachment’s file once the save is committed, so a refused or rolled-back save keeps it — only a transaction rolled back after a successful save keeps the new file too; needs the reactor wiring UseDefaults() sets — without it, a replaced file goes once the save succeeds and a deleted one during the save), and .For<Product>(e => e.HasAttachments<AppDbContext, Product, ProductAttachment>(x => x.Attachments)) for the typed per-owner services + link prepper + DTO mapping. HasAttachments is an extension on the base EntityServiceBuilder, so it chains on every For<>() tier — a complex owner registers it exactly like the simple one shown here. The bytes Details loads are the stored file, not new content, so saving a rename or another metadata edit leaves the file where it is; bytes or a stream set in their place replace it, stored under the file name’s extension.options.UseAttachmentUris() (before registering entities, on the same UseEntities options instance) and register AddHttpContextAccessor() so attachment DTOs resolve a Uri linking to the attachment controller’s GetFile action.⚠️ A link rule belongs in the owner’s validator too. Validators scoped to the link entity run for the attachment endpoints — upload, replace, update and delete. A
PUTof the owner whose input carriesAttachments(step 3) syncs the links itself: it adds one for each new entry withNewBytes, renames and replaces a kept link’s file from itsNewFileNameandNewBytes, and deletes the ones the array leaves out, and only the owner’s validators run. A kept link keeps its attachment, whateverattachmentIdthe entry sends, and a new one may point only at an attachment the owner already links: one naming another owner’s is cleared, and withoutNewBytesof its own the save answers 409. The upload route always creates a link, whateverIdits form sends. Repeat a link rule — allowed file types, a file that must not be deleted — in the owner’s validator (keys likeAttachments[0].NewFileName), or keepAttachmentsoff the owner’s input DTO.
⚠️ Marking one attachment as the primary one? Mark the link entity, don’t point the owner at it. An owner FK to one of its own attachments makes the two tables reference each other: SQL Server refuses the migration and every owner
DELETEanswers 500. UseSortOrderor a flag on the link entity; startup validation warns, andentities.patterns.md→ An entity that references one of its own children covers the case where the FK has to stay.
⚠️ Owner is
IArchivable? The link entity is separately registered and has no navigation back to its owner, so archiving the owner leaves its attachments visible to/{ownerId}/attachments. Startup validation flags the shape; the working model configuration is inentities.patterns.md→ Soft Delete > Attachments on an archivable owner.
Reads: eager-load the owner’s
Attachments, or the file metadata comes back null.HasAttachmentswires the write side and the second hop (EntityAttachment → Attachment) on the attachment service — but the owner’s own List/Details must register the first hop. Add it to the owner’s includes withe.Includes((q, _) => q.IncludeEntityAttachments())(Regira.Entities.EFcore.Attachments), i.e..Include(x => x.Attachments!).ThenInclude(a => a.Attachment). Including onlyx.Attachmentsloads the join rows but leavesattachmentnull on the DTO — sofileName,contentTypeandlengthare all missing.uristill resolves: with noAttachment.FileNameto build the filename route from, the resolver falls back to the by-idfiles/{id}link.⚠️ The link DTO nests the file.
id,objectId,attachmentId,objectType,sortOrderanduriare flat;fileName,contentType,lengthand the timestamps live one level down underattachment(EntityAttachmentDto.Attachment, anAttachmentDto). Readinglink.fileNameyieldsundefined, not an error — a payload is in §Response Types.Ordered by
SortOrder? Write the two hops out.IncludeEntityAttachments()takes no ordering argument, so a UI that lets the user drag attachments into an order needs the filtered include instead of the helper — the same two hops, plus theOrderBy.HasAttachmentsassignsSortOrderfrom the incoming array position on every parent save, so this is what makes the saved order survive a reload:e.Includes((q, _) => q.Include(x => x.Attachments!.OrderBy(a => a.SortOrder)).ThenInclude(a => a.Attachment)).
⚠️
HasAttachmentis a property nothing fills and a filter nothing binds — both halves are yours. Reads: it serializesnullunless you set it on the read path (anIEntityProcessor, or a mapped projection); a paperclip icon bound to it is otherwise always empty, so leave it off the DTO if you do not populate it. Filtering: declarebool? HasAttachmenton yourTSearchObjectand applyquery.FilterHasAttachment(so.HasAttachment)(Regira.Entities.EFcore.Extensions— it queriesAttachments.Any(), not the property) in the query builder. Without the SearchObject property,?hasAttachment=truebinds to nothing and the request returns every row with a 200 — the general shape of an unbound filter parameter, so verify each new filter returns fewer rows than no filter at all.
The
Uriisnull, never an error. All four causes: the option was omitted, or set on a differentUseEntitiesoptions instance than the one the entity was registered on (both leave theNullAttachmentUriResolverin place); no controller named after the attachment type is mapped, or its download route was replaced by a custom endpoint; or there is no active request (seeding, background work). Startup validation warns for the first two whenever an attachment controller is mapped, and the resolver itself warns for the third naming the controller names it tried. A stable alternative is to compose{ownerRoute}/files/{id}client-side and skip the option entirely.
FileNamecarries the client’s virtual folders; storage never does.FileNameis the client’s own value and may be a path (folder1/folder2/report.pdf) — that is how attachments are organised. The identifier is generated independently ({Owner}/Attachments/{ObjectId}/{slug}-{guid}{ext}), so re-filing is just writing a differentFileName: the download URL changes, the bytes never move. Downloads use a catch-all ({objectId}/files/{*fileName}) so a multi-segment name resolves. Empty and relative segments are dropped on write, since the value is echoed into URLs.
Reading bytes in custom code: inject
IAttachmentFileService<Attachment, int>and callGetBytes(item). Consuming code references files byIdentifier(the public storage key, populated when you load through the entity service);Pathis internal and isn’t mapped to DTOs — clients get a downloadUriinstead.
Ordering & extending the DTOs. Attachment order travels by array position, not by a client-sent value: on every parent save the pipeline assigns
SortOrder = indexover the incoming collection (SetSortOrder(), wired byHasAttachments), so the input DTO carries noSortOrderon purpose.EntityAttachmentand the readEntityAttachmentDtoexpose it — order the eager-load (x.Attachments!.OrderBy(a => a.SortOrder)) so a round-trip is stable. The DTOs arerecords, so a derived DTO must also be declaredrecord(CS8865). Extra client-supplied fields belong on a derived input DTO; a derived read DTO works too (theUriafter-mapper matches attachments by theEntityAttachmentDtobase type).
Images in
<img>tags 401 on a secured API — the browser sends noAuthorizationheader forsrcURLs. Expose the download anonymously (uploads/deletes stay guarded):entities.patterns.md→ Public (anonymous) attachment downloads.
⚠️ Owner scoped per user or tenant? Its files are not, until you scope the link. The owner’s global filter never runs on the link entity, so every attachment route reads across owners. Add a global filter on the link that reruns the owner’s scope, plus a validator for the upload: §Security & Authorization.
A validator’s errors (§Step 8 → Validators), or an EntityInputException<TEntity> thrown from a prepper or an
action, become a 400 ValidationProblemDetails — "errors": { "Code": ["…", "…"] }, plus errorDetails with
each error’s args (§Response Types) — built from Errors, which carries every message, several per field
(InputErrors is a view over it: entities.signatures.md).
With no errors, the exception’s message goes out under the empty key. The generated DELETE answers a refused delete
the same way. ConfigureDefaultJsonOptions() registers the exception filter that maps it application-wide,
so a hand-written domain action (POST {id}/approve) answers exactly like the generated PUT — one of the two
reasons that call is not optional.
Catching it yourself — to add context, or to convert it — catch the non-generic base
EntityInputException. The generated write actions catch their own closed generic, so anEntityInputException<Product>thrown by a prepper during anOrderwrite escapes them; the base is what the filter matches, and what your owncatchshould. Worked example:entities.patterns.md§ Domain actions on an entity resource.
→ See:
entities.examples.md— Order + OrderLine entities (the order-lines validator inAddOrders)
SaveChanges() wraps a database integrity-constraint violation (unique index, FK, NOT NULL, check —
detected per provider: SQLSTATE class 23, SQLite error 19, SQL Server 547/515/2601/2627) in
EntityConstraintException; every write surface returns 409 Conflict — the controller bases, the
attachment controllers, and any hand-written action, through the same filter
ConfigureDefaultJsonOptions() registers. The response detail is generic — the provider’s
constraint message can leak index names and other users’ values, so it is logged server-side (warning) by
the write service instead. Transient faults (deadlocks, timeouts) are not wrapped and keep surfacing as
500s for alerting. When the client can fix the input, prefer an explicit check in a validator — a
field-level 400 beats a generic 409.
A write built on a stale read — the row no longer holds the concurrency token the client sent, or another
writer removed it — surfaces as EntityConcurrencyException (EF’s DbUpdateConcurrencyException is its
InnerException). Every write surface returns 409 Conflict through the same filter, with a ProblemDetails
titled “Concurrency conflict”, so a client can tell “reload and try again” from the constraint 409. Tokens,
the DTO rules and what each route checks: entities.patterns.md → Optimistic
concurrency.
→ See:
entities.examples.md— Order + OrderLine entities
Treat Many-to-Many as two One-to-Many relations using a middle/join table with an explicit join entity. Always create an explicit join entity — even if the join table carries no extra properties, having a dedicated entity makes the collection easier to manage via e.Related().
e.Related()DbContext.OnModelCreating.Related()) to synchronize join table changes when updating→ See:
entities.examples.md— Product entity
entities.patterns.mdLoad that file when implementing one of these:
SaveChanges(); includes multi-wave seeding (Id/change-tracker timing).IsActive (or any one field) via PATCH /{id}; covers toggling owned join rows.[ServerOwned]/e.ServerOwned(…) so PUT/PATCH can’t null or re-mint a code, total or owner FK; plus the prepper and primer forms for what a declaration cannot cover.REQ-2026-00001 from a primer on Added and restore it on Modified; includes when that primer has to be a prepper instead, and why the counter is primed from the highest code.POST /{id}/approve) as a second controller on the entity’s route, answered with a re-read; role-gated transitions for privileged states, the append-only history shape, and where what follows a transition belongs.POST too.e.Prepare(entity, dbContext) overload; EntityInputException<T> must name the serviced entity or it escapes as a 500.UseMapping chain when a DTO name differs from the entity’s (Mapster maps by name only).<img> on a secured API ([AllowAnonymous] override of GetFile).DELETE both fail. What to do instead, and the two-phase save when the reference has to stay.IArchivable round-trip: DELETE archives instead of erasing, which routes see archived rows, and what restore requires.SortOrder vs a per-row flag.CreatedBy/ModifiedBy via a global primer.[MaxLength] before save.AddXInterceptors() to register.entities.blueprints.mdReady-to-copy feature slices (complete models + DbContext config + registration + DTOs), proven in the Regira reference apps. Load that file when the task matches one of these:
Q.Contact table.Q.IHasTenantId marker + one global filter (scope every read) + one primer (stamp every write); tenant claim in the JWT.AncestorId/OffspringId) via mapped recursive-CTE table-valued functions, plus tree endpoints with Regira.TreeList.IEntityRepository over UserManager<TUser> so users get the standard entity endpoints.IEntityService without a table.All base controller endpoints return typed wrappers (DetailsResult, ListResult, SearchResult, SaveResult, DeleteResult). Wire format (camelCase):
| Endpoint | Body |
|---|---|
GET /{id} |
{ "item": { … }, "duration": 5 } |
GET / (List) |
{ "items": [ … ], "duration": 5 } — no count |
GET /search, POST /search |
{ "items": [ … ], "count": 42, "duration": 5 } |
POST / PUT / PATCH (Save) |
{ "item": { … }, "isNew": true, "affected": 1, "duration": 5 } |
DELETE |
{ "item": { … }, "duration": 5 } |
Populated, so a client can be typed against it without calling the API first. item/items carry your DTO
verbatim; everything around them is the wrapper:
// GET /api/products/search?q=lamp&pageSize=2
{ "items": [ { "id": 12, "code": "LMP-001", "title": "Desk lamp", "categoryId": 3,
"category": { "id": 3, "title": "Lighting" }, // nested DTO — see the warning below
"created": "2026-08-13T09:12:44Z" } ],
"count": 42, "duration": 5 }
// POST /api/products/save → 200
{ "item": { "id": 13, "code": "LMP-002", "title": "Floor lamp" }, "isNew": true, "affected": 1, "duration": 7 }
// EntityInputException → 400, a ValidationProblemDetails: "errors" maps each key to its messages — a text or a
// translation key (§Step 8 → Validators) — and "errorDetails" lists every error in order with its args, "args" left out
// when the error has none. Keys are the error keys verbatim — System.Text.Json applies no dictionary-key policy — so
// nameof(Product.CategoryId) reaches the client as "CategoryId". A host that sets DictionaryKeyPolicy, or serializes
// with Newtonsoft's camelCase resolver, camelCases them, and the args' names too.
{ "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", "title": "One or more validation errors occurred.",
"status": 400, "traceId": "00-6f2d…-01",
"errors": { "CategoryId": ["Category 99 does not exist."], "Price": ["ValueTooLarge"] },
"errorDetails": [
{ "key": "CategoryId", "message": "Category 99 does not exist." },
{ "key": "Price", "message": "ValueTooLarge", "args": { "max": 10000 } } ] }
// Model binding / DataAnnotations failing first is the same ValidationProblemDetails without "errorDetails" —
// [ApiController]'s automatic 400. A DataAnnotations failure is keyed by the C# property name; a JSON conversion
// failure by its path ("$.credits").
{ "title": "One or more validation errors occurred.", "status": 400,
"errors": { "Credits": ["The field Credits must be between 0 and 5."] } }
// GET /api/products/7/attachments — a List endpoint, so no "count"; the file metadata is NESTED
{ "items": [ { "id": 5, "objectId": 7, "attachmentId": 91, "objectType": "Product", "sortOrder": 0,
"uri": "https://localhost:5001/api/products/7/files/manual.pdf",
"attachment": { "id": 91, "fileName": "manual.pdf", "contentType": "application/pdf",
"length": 20481, "created": "2026-08-13T09:12:44Z" } } ],
"duration": 4 }
⚠️ A null value is absent from the payload, not null. ConfigureDefaultJsonOptions() sets
DefaultIgnoreCondition = WhenWritingNull, so an unset timestamp or a foreign key an action just cleared is
omitted — which is why no sample above carries a null. A client asserting assignedEmployeeId === null
after the action that cleared it fails against a correct API: test for absence (== null), and never read a
missing key as “this entity has no such field”.
⚠️ Nest a Core/summary DTO and its collections are absent, not empty. A UI reading status.transitions
off a nested StatusCoreDto gets undefined, with no error — and on the front end fromPool rehydrates it
into the real model class, so it looks right in a debugger. Widen the nested DTO, or load that entity’s
own endpoint.
→ See:
entities.signatures.md— Response Types
| Interface | Properties | Related Services |
|---|---|---|
IEntity<TKey> |
Id (TKey) |
FilterIdsQueryBuilder |
IEntityWithSerial |
Id (int) |
(same as IEntity<int>) |
IHasCode |
Code (string?) |
Normalizers |
IHasTitle |
Title (string?) |
Normalizers, FilterTitle |
IHasDescription |
Description (string?) |
Normalizers |
IHasNormalizedContent |
NormalizedContent (string?) |
FilterHasNormalizedContentQueryBuilder |
IHasCreated |
Created (DateTime) |
HasCreatedDbPrimer, FilterHasCreatedQueryBuilder |
IHasLastModified |
LastModified (DateTime?) |
HasLastModifiedDbPrimer, FilterHasLastModifiedQueryBuilder |
IHasTimestamps |
Created, LastModified |
Both timestamp services |
IArchivable |
IsArchived (bool) |
ArchivablePrimer, FilterArchivablesQueryBuilder, archived query filter (DbContextWiring.ArchivedQueryFilter) |
IHasConcurrencyToken |
ConcurrencyToken (Guid) |
HasConcurrencyTokenDbPrimer, concurrency-token convention (DbContextWiring.ConcurrencyTokens) — entities.patterns.md → Optimistic concurrency |
ISortable |
SortOrder (int) |
RelatedCollectionPrepper, EntityExtensions.SetSortOrder |
IHasStartDate |
StartDate (DateTime?) |
(contract only) |
IHasEndDate |
EndDate (DateTime?) |
(contract only) |
IHasStartEndDate |
StartDate, EndDate (both DateTime?) |
QueryExtensions.FilterIsActiveOn(date) — call it yourself; no global filter |
IHasObjectId<TKey> |
ObjectId (TKey) |
Attachments |
IHasAttachments |
HasAttachment, Attachments |
Attachments module |
The date interfaces are contracts, not automatic filtering.
StartDate/EndDateare nullable (implementing them with non-nullableDateTimefailsCS0738), and no global filter consumes them — nothing narrows a query for you. There is one built-in helper you call yourself:query.FilterIsActiveOn(date)(Regira.Entities.EFcore.Extensions.QueryExtensions, constrained toIHasStartEndDate), which matches rows whose period containsdate, treating either bound as open when null. If your period is mandatory, plain non-nullableStart/Endproperties of your own are the better fit — and then the range filter is yours to write.
Nullability convention:
IHasCode/IHasTitle/IHasDescription/IHasNormalizedContentdeclare their strings asstring?— a contract convention, not enforcement. Need non-null? Add[Required]on the implementing property; thestring?declaration stays — ❌public string Code { get; set; } = null!;narrows the interface setter and warnsCS8767, ✅[Required] public string? Code { get; set; }.
Registered by options.UseDefaults(); can also be registered manually.
| Class | Applies to | Filters on |
|---|---|---|
FilterIdsQueryBuilder |
All entities | Id, Ids, Exclude |
FilterArchivablesQueryBuilder |
IArchivable |
Archived — null falls back to EntityQueryOptions.DefaultArchivedFilter (Excluded). Translates the opt-ins only; hiding archived rows is the archived query filter (auto-wired by UseDefaults()) |
FilterHasCreatedQueryBuilder |
IHasCreated |
MinCreated, MaxCreated (interpreted as UTC) |
FilterHasLastModifiedQueryBuilder |
IHasLastModified |
MinLastModified, MaxLastModified (interpreted as UTC) |
FilterHasNormalizedContentQueryBuilder |
IHasNormalizedContent |
Q keyword search |
Each of these has a TKey-generic variant; the parameterless names are int-keyed. A global filter only
accepts search objects matching its own key type, so non-int entities need the matching variant registered
(AddDefaultGlobalQueryFilters<TKey>() does this automatically).
| Class | Applies to | Behaviour |
|---|---|---|
HasCreatedDbPrimer |
IHasCreated |
Sets Created (UTC) on insert; normalizes client-supplied values to UTC |
HasLastModifiedDbPrimer |
IHasLastModified |
Sets LastModified (UTC) on update |
ArchivablePrimer |
IArchivable |
Soft-delete: sets IsArchived = true and writes nothing else of the row (what the later primers stamp aside); undoes EF’s cascade to the loaded dependents first, so they stay as they are — a dependent removed in the same raw save as its parent is kept too |
HasConcurrencyTokenDbPrimer |
IHasConcurrencyToken |
Mints a new ConcurrencyToken on every update (a soft delete included) and on insert when empty |
AutoTruncatePrimer |
All entities | Truncates strings to [MaxLength] |
| Interface | Implementation | Role |
|---|---|---|
INormalizer |
DefaultNormalizer |
Normalizes a string value |
IObjectNormalizer |
ObjectNormalizer |
Processes [Normalized] attributes |
IEntityNormalizer |
DefaultEntityNormalizer<IEntity> |
Orchestrates entity normalization |
IQKeywordHelper |
QKeywordHelper |
Parses Q search strings with wildcard support |
Generated endpoints ship anonymous — no controller base carries [Authorize]. Every scaffolded endpoint, including delete and attachment download, is public until the app adds authorization.
[Authorize] on your controller subclass (use [AllowAnonymous] per action for exceptions): [Authorize] public class ProductController : EntityControllerBase<Product, ProductDto, ProductInputDto>;IHttpContextAccessor in its constructor and filter on the claim. Register it once, app-wide, with options.AddGlobalFilterQueryBuilder<OwnerFilter>() inside UseEntities(options => …) (using Regira.Entities.DependencyInjection.QueryBuilders;), not per entity: an e.AddFilter<T>() filter is bound to that entity’s search object and is not a global filter. The claim reaches the principal the same way whichever scheme authenticated the caller (bearer token, cookie session, API key), so the filter needs no knowledge of which one is in use. The filter pipeline runs on every controller path: List, Search, Details(id) (the id goes through the same filters), and the write endpoints’ existence checks — so PUT/PATCH/DELETE on a foreign row 404 as well.OwnerId from the claim in a prepper, never trust the body) or guard direct IEntityService calls in custom code, which bypass the controller’s filtered existence checks. Two variants of the create hole bite hardest:
ProductAttachment) has no owner column and no navigation back, so a filter that scopes the owner never runs on it. Every attachment route — GET attachments, attachments/{id}, {objectId}/attachments, both downloads (files/{id}, {objectId}/files/{name}), and the link’s PUT and DELETE — then reads across owners: any signed-in user lists, downloads, edits and deletes every user’s files. Give a row-scoped owner with attachments a second global filter, on the link entity, that reruns the owner’s scope through the owner’s DbSet (db.Products.Any(p => p.Id == link.ObjectId && p.OwnerId == userId)) with the same exemptions, registered app-wide beside the owner’s — the shape is in entities.examples.md → Attachments. With it, those routes answer 404 for another owner’s file; check one by id as a second user.POST /{owner}/{id}/files takes the owner id from the route, stamps it on a new link row and saves — no query runs, so no global filter applies, and any authenticated caller can attach a file to a row they cannot read. Add a validator on the link entity that re-runs the owner’s scope over the owner’s DbSet on Add and adds an error when it resolves nothing — which answers 400, not the 404 the read path gives a foreign row, since the write pipeline maps only 400 and 409. Override the controller’s virtual Add and return NotFound() instead where the two must agree.PATCH/DELETE on them too. When the two differ, keep the filter at read width and put the ownership check in a validator, which runs on a delete too (no prepper does).if (so == null) return query; — for a security filter that is a hole, because Details(id) and the write existence checks can run with a null search object and would skip the scoping entirely. Derive from GlobalFilteredQueryBuilderBase<TEntity> (it runs on every query and takes no search object), apply the ownership predicate unconditionally, and return query.Where(_ => false) when no identity resolves — an anonymous or stale-token call must see nothing, not everything. A seeder or hosted job has no request either, so its IEntityService reads see nothing too — and so does Modify, whose re-read of the stored row then finds none and saves nothing: give it an explicit identity through a context it sets (the shape of WritableTenantContext in entities.blueprints.md → Multi-tenancy — IHasTenantId + global filter + primer), or have it read the DbContext directly. Don’t equate “no HttpContext” with “system”: work started from inside a request inherits that request’s context.TEntity the entity satisfies runs, and their predicates compose — so an IHasUserId-wide filter and a ShoppingList-specific one both apply. TEntity may be an interface, a base class, or the concrete entity type. The one case that does not stack is the key variants of a single filter family (FilterArchivablesQueryBuilder vs <Guid>): one variant runs, preferring the key-matching one. Two filters deriving separately from GlobalFilteredQueryBuilderBase<> are always distinct families and never suppress each other. A filter scoped to a type no registered entity satisfies never runs at all — startup validation warns about this, which is your signal that a security filter is inert.AddAuthorization(o => o.AddPolicy("EditorOnly", p => p.RequireClaim(...)))) and gate the baseline with MapControllers().RequireAuthorization("AdminOrEditor"). For “everyone reads, some roles write”, one global filter carries the tier — worked recipe with the traps in entities.patterns.md § Role-gated write authorization filter. ⚠️ Key that filter on your controller types and let it fail closed — a write to a controller it has no entry for, the account controllers included, is refused — and remember POST /{entity}/search and POST /{entity}/list are reads. Make it an authorization filter, which runs before model binding, and exempt only a write whose action carries [AllowAnonymous] itself: a controller opened for anonymous reads with [AllowAnonymous] keeps its writes gated. ⚠️ RequireClaim/RequireRole and any hand-written claim read must use the spelling the validated principal carries, and getting it wrong costs rows, not errors (next bullet). The claim contract is one lookup away in security.instructions → Claims emitted per scheme and Claim normalization. The schemes do not all agree on the role claim type (role, Entra’s roles, and the long ClaimTypes.Role URI are all in play), so read roles with User.FindRoles() and scopes with User.HasScope() rather than a single HasClaim; on a normalized principal — every scheme except the API key — the canonical sub/name/email/role spellings are present alongside the provider’s, so RequireClaim("role", …) does hold.GET /{entity}/search totals: an administrator sees more than an owner, a second tenant sees none of the first’s. A filter that never ran, a role claim that did not survive validation, and a scope matching no registered entity all answer 200 with fewer rows — invisible to a build, to DI validation, and to a single-user smoke test. Do this once per app after the first scoped entity works, then whenever a filter or claim changes.Content-Type a client declared, whoever writes the row — and a download is served with X-Content-Type-Options: nosniff and, for every file but a PDF, Content-Security-Policy: sandbox, so it runs no script on the API’s origin. Still restrict extensions at the app level when accepting uploads from untrusted users.Grouped by symptom — fetch one group, not the whole table. Most rows are silent failures: a clean build, a 200, and a wrong result. If nothing matches: signatures → entities.signatures.md, namespaces → entities.namespaces.md, a working example → entities.examples.md.
| Problem | Likely Cause | Fix |
|---|---|---|
| Navigation properties not loaded | Missing Includes config or wrong flag |
Check e.Includes(...) and that the client sends the correct includes flag |
| Nested collection comes back empty in an API response | The navigation was never eager-loaded — almost never a mapping problem | Add the eager-load in e.Includes(...) (never Filter(...) — filters pick rows, includes load navigations). Only add AddMapping<…>() if the child DTO shape genuinely diverges from the entity (§Step 10) |
A relation loads, then vanishes from responses after adding another Includes(...) |
Includes(...) registrations are last-write-wins — the second call replaced the first (single IIncludableQueryBuilder) |
Merge all eager-loads into one Includes lambda (§Step 4) |
| Filter not applied | Query builder not registered or wrong SearchObject property name |
Verify e.AddFilter<>() or e.Filter(...) and check property names |
A list page comes back short (items < pageSize) while /search still counts the rows; the shortfall looks like an off-by-one in paging |
An IArchivable principal behind a required FK. On net10.0 the archived filter propagates into the Include as an inner join, so a dependent whose parent is archived drops out of items; the count query has no join. A sort that interleaves parents (SortOrder, Title) loses about one row per page, which is why it reads as a paging bug. Startup validation warns on the shape |
Reference data: don’t make it IArchivable — real DELETE + OnDelete(Restrict), or an optional FK. An aggregate parent whose dependent you also query directly: mirror the filter on the dependent (entities.patterns.md → Soft Delete → Before you make reference data IArchivable) |
?q= matches nothing — every search empty, 200, no error, no warning |
The entity implements IHasNormalizedContent but nothing fills NormalizedContent: [Normalized] sits on the class instead of the property, or there is no attribute at all. The global Q filter then ANDs a match against null |
Put [Normalized(SourceProperties = [...])] on the NormalizedContent property (§Step 1 — all-or-nothing note, §Normalizing). Rows saved before the attribute existed stay empty until re-saved |
?q= returns everything, unfiltered |
The entity doesn’t implement IHasNormalizedContent and has no custom filter — startup logs “?q= text search is silently ignored for: X” |
Implement IHasNormalizedContent with a [Normalized] source (§Step 1) |
?q= finds fewer rows than it should, or none once a domain filter is on |
A per-entity Q filter in your query builder ANDs on top of the global one |
Delete it — keyword search is already global for IHasNormalizedContent entities (§Step 6 — Don’t write a Q filter) |
?q= returns nothing although the text is clearly in the row |
NormalizedContent holds raw text while the query term is normalized — punctuation the normalizer deletes never matches |
Write NormalizedContent through [Normalized] or INormalizer.Normalize(...) (§Normalizing — the normalize contract) |
A computed / [NotMapped] field is null on a row nested inside another entity’s response, but filled on the entity’s own endpoint |
Processors and after-mappers run only in their own entity’s read pipeline — never inside another entity’s projection | Expected. Render null as empty (not 0), or fetch the child list from its own endpoint (§Step 7) |
A row vanished after DELETE and no payload brings it back |
The entity is IArchivable: the row is archived, lists hide it and GET /{id} 404s |
Keep IsArchived on TInputDto and send false; read the row with ?archived=included (or list the recycle bin with ?archived=only) (entities.patterns.md → Soft Delete) |
| Mapping errors | Mapster/AutoMapper not configured or property name mismatch | Ensure options.UseMapsterMapping() is called; check DTO property names |
| Problem | Likely Cause | Fix |
|---|---|---|
| Save not persisting | SaveChanges() not called |
Base controllers call it automatically; direct IEntityService callers (services, jobs, seeding) must await service.SaveChanges() themselves. |
| A related collection gets emptied or its rows reassigned after saving the parent | The parent’s DTO carried the collection, so Related() diffed it as owned and overwrote it to match — an empty list deletes every row (only null is a no-op). Common when the element entity is also independently managed via its own .For<>()/IEntityService<T> |
First check the parent’s TInputDto: dropping the collection from it makes the sync a no-op and lets both coexist. If the parent must send it, pick one authority — drop the .For<>(), or drop the Related() and load via Include() in the query builder (§Relationship Patterns — Decision Table) |
| Owned child rows are gone after a save that never touched them (a status-only PATCH) | An omitted collection reached the sync as [] (= delete all) instead of null (= untouched): the input DTO initializes it (= []) or declares it non-nullable, or the client sent an empty array |
Declare it ICollection<TChildInputDto>? Lines { get; set; } — nullable, no initializer (§Step 5); a client that isn’t editing the rows omits the property |
| A row is inserted with a client-supplied id, or SQL Server answers “Cannot insert explicit value for identity column” | A positive id that matches no stored row is treated as an explicit insert — a stale key for a row someone else deleted. Only negative temp keys are cleared; 0 and null were never set |
Reload before saving instead of resending the stale row (§Step 8 — How the sync classifies an incoming row) |
| A computed total zeroes after a PATCH that didn’t touch the lines | The prepper collapsed null (collection not sent) and [] (delete-all) into one branch and summed an absent collection |
Branch on null and re-read the persisted children before summing (§Step 5; entities.examples.md — Additional Patterns > Prepper) |
Edits to entities saved in an earlier SaveChanges wave don’t persist |
EF change tracker is cleared after every SaveChanges() — the entity is now detached; later mutations are silently dropped |
Re-attach with await service.Modify(entity) (or Save), then call await service.SaveChanges() again |
DELETE archives the row but it still shows up in lists, GET /{id} and included collections |
No archived query filter on the model — the registered builder translates the opt-ins only. Either the wiring never reached the context (non-generic UseEntities(), or WireDbContext(...) without ArchivedQueryFilter), or the DbContext was constructed by hand and never saw the service collection |
Register with UseEntities<TContext>(e => e.UseDefaults()); for a hand-built context add .AddArchivedQueryFilter() to its options (entities.patterns.md → Soft Delete). Startup validation reports the DI case as an error |
Restore 404s / a repeated DELETE 404s, only on an entity with a custom read service |
The service implements Details(id, ct) only and inherits the default Details(id, archived, ct), which cannot see archived rows |
Override both Details overloads on the custom read service / repository |
DELETE erases the row instead of archiving it |
IArchivable not implemented, or ArchivablePrimer not registered |
Implement IArchivable; use UseDefaults() |
A Restrict FK lets the parent delete anyway (no 409) |
SQLite enforces foreign keys only when the connection string sets Foreign Keys=True |
Add it to the connection string (entities.setup.md → P3) |
Save/delete returns 409 Conflict (ProblemDetails title “Conflict”) on a valid-looking payload |
A DB constraint rejected the change — required FK points at a nonexistent parent, duplicate unique key, or a delete under Restrict (§Error Handling); the response detail is generic — the constraint name is in the server log (warning) |
Fix the data, or check it in a validator → field-level 400 (§Step 8 → Validators) |
| A user’s edit silently overwrites another user’s change — no 409 | No concurrency token, or it never round-trips: missing from the read or input DTO (the client then sends the default, which is not checked), or an application-owned token nothing re-mints | Implement IHasConcurrencyToken (or declare a token of your own and mint it in a primer) and put it on both DTOs (entities.patterns.md → Optimistic concurrency). Startup validation warns about a DTO without the property |
| Every PUT/PATCH answers 409 “Concurrency conflict”, even with a single user | The entity initializes its token (= Guid.NewGuid()) and the input DTO has no property for it, so each mapped entity carries a token the row never held; or the client resends the token it read before its own last save |
Remove the initializer and add the property to both DTOs; take the new token from the SaveResult after each save. Startup validation reports the initializer as an error |
| A dashboard/report endpoint answers 500 with an empty body on a green build; the log says “The LINQ expression … could not be translated” or “Translating this query requires the SQL APPLY operation” | An untranslatable construct in the query: a record constructor in the projection, a correlated SelectMany (CROSS APPLY — absent on SQLite), a method of your own called on the row, date arithmetic between two columns on SQLite (x.End - x.Start), or a provider-specific EF.Functions member (DateDiff* is SQL Server only) |
The full list with the translating alternative for each is in entities.patterns.md § Cross-entity aggregates & report endpoints |
| Problem | Likely Cause | Fix |
|---|---|---|
| CS0246: type or namespace name could not be found | Namespace guessed or copied from wrong source | Look up the exact namespace in entities.namespaces.md |
CS0246 on FilteredQueryBuilderBase<> / IFilteredQueryBuilder<> / ISortedQueryBuilder<> / IIncludableQueryBuilder<> |
Reached for Regira.Entities.EFcore.QueryBuilders (which holds the concrete implementations) |
These bases and interfaces live in Regira.Entities.QueryBuilders.Abstractions — the EFcore namespace only holds the concrete QueryBuilder<>/SortedQueryBuilder<> types you rarely reference directly |
CS1593: wrong number of args on SortBy(...) |
Using two-arg lambda (query, sortBy) => with a simple For<> builder that has no TSortBy type parameter |
Simple builders take a 1-arg lambda: e.SortBy(query => ...). The 2-arg form (query, sortBy) => is only available with complex builders — For<TEntity, TSearchObject, TSortBy, TIncludes>() or .Complex<TSortBy, TIncludes>() |
CS1061: no .AfterInput after .After<TImplementation>() |
The class-based After<T>() returns the untyped builder — TDto/TInputDto are dropped |
Chain the typed .After(...)/.AfterInput(...) inline on the UseMapping<TDto, TInputDto>() builder; use .After<T>() only when nothing typed follows |
List(null) compiler error |
Ambiguous overload between typed and untyped variants | Omit the argument (service.List()) or cast: service.List((TSearchObject?)null). Count differs by interface: the typed IEntityService<…, TSearchObject> has a parameterless Count(), but the universal IEntityService<TEntity, TKey> exposes only Count(object? so) — call await service.Count(null) when you hold the universal interface (e.g. seeding). |
SetSortOrder() won’t resolve on a child collection |
The extension targets IEnumerable<ISortable>; it resolves on ICollection<T>/IEnumerable<T> only when the element type T statically implements ISortable (covariance) |
If T : ISortable, call it directly: items.SetSortOrder(). If the element type isn’t statically ISortable, cast first: (items as IEnumerable<ISortable>)?.SetSortOrder() |
| Wrong method name, parameters, or return type | Signature guessed or assumed | Look up the exact signature in entities.signatures.md |
| Problem | Likely Cause | Fix |
|---|---|---|
| Normalizer not running | Interceptor not wired — no UseDefaults() and no WireDbContext(NormalizerInterceptors) |
Call UseDefaults() (or e.WireDbContext(DbContextWiring.NormalizerInterceptors)) — startup validation fails fast on this in Development |
| Primers not running | Interceptor not wired — no UseDefaults() and no WireDbContext(PrimerInterceptors) |
Same as above |
| A validator never runs | Its scope matches no registered entity (a Related() child is validated through its parent); the write service calls EntityWriteService’s constructor without validators; a custom repository does not take IEnumerable<IEntityValidator>; or the write path takes them and skips them — a custom repository that never calls ValidateItem, an override of Remove that skips base.Remove |
Startup validation warns about the first three; the last one it cannot see — §Step 8 → Validators |
| Reactor never runs | Interceptor not wired (no UseDefaults(), no DbContextWiring.Reactors); or the save ran inside a transaction that was never committed through EF |
Wire it (startup validation warns); commit through the IDbContextTransaction — §Step 9 → Reactors |
EntityControllerBase<> constructor errors / DI fails to resolve controller |
Explicit constructor injecting IEntityService<> declared inside the controller class |
Drop the IEntityService<> parameter — EntityControllerBase<> resolves its service internally via the framework; a constructor is needed only for what your own actions use |
EntityWrappingServiceBase — infinite loop |
Inner service is the wrapper itself | Ensure UseEntityService<T>() registers the wrapper; AddTransient registers the interface |
Startup throws a LicenseException naming entities |
A registration bucket is full (free tier: 5 simple + 2 complex) | Compare the startup log line N simple / N complex registered → tier = against your §Step 0 tally, then apply an overflow remedy from §Step 0 |
| Problem | Likely Cause | Fix |
|---|---|---|
dotnet restore fails with “Detected package downgrade” |
Project targets a framework that is lower than what a dependency requires | Use latest <TargetFramework> in the .csproj |
Clean build, then System.MissingMethodException (e.g. RelationalQueryCompilationContext..ctor) on the first query |
The EF Core provider package major doesn’t match the EF Core version Regira targets for your TFM (Regira’s EF Core packages multi-target) | Pin the provider (Microsoft.EntityFrameworkCore.Sqlite/.SqlServer/Npgsql.EntityFrameworkCore.PostgreSQL) to the matching major: net8.0/net9.0 → 9.x, net10.0 → 10.x |