Regira-Packages

Regira Entities AI Agent Instructions

A generic, extensible framework for managing data entities in .NET with standardized CRUD operations, filtering, sorting, and includes.

Projects

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.


License requirement

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.


Quick Agent Playbook

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.


References

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.


Core Understanding

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.

Generic Type System

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/EntityIncludes are the default types, not request-driven behavior on simple entities. A simple registration applies one fixed SortBy(q => q.OrderBy(...)) order and ignores the request’s ?sortBy=; ?includes= is likewise not bound on simple List/Search. A gated Includes(...) navigation therefore loads only on Details (which passes EntityIncludes.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 complex For<…, TSortBy, TIncludes>() path (SortBy((q, key) => key switch { … }) + typed [Flags] includes) — only complex bases bind ?sortBy=/?includes=.

Processing Pipelines

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.


Decision-Making Guidelines

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


Project Creation Workflow

(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> vs EntityServiceCollection<T>: UseEntities<TContext>() returns the concrete EntityServiceCollection<TContext>, which implements IEntityServiceCollection<TContext>. Write your extension methods with the interface as the this parameter (this IEntityServiceCollection<TContext> services) but declare the return type as the concrete EntityServiceCollection<TContext> — every For<>() overload already returns the concrete type, and only the concrete type implements IServiceCollection. This means chains can be assigned to or returned as IServiceCollection without any extra unwrapping call.

EntityServiceCollection<TContext> implements IServiceCollection — it inherits from ServiceCollectionWrapper : IServiceCollection, so returning it from a method typed IServiceCollection compiles directly. You can also call .GetServices<TContext>() to explicitly unwrap to IServiceCollection if needed, e.g. when the chain is broken across multiple statements.


Step 0 — Classify every entity before scaffolding

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<>() without TSortBy/TIncludes; complex = For<…, TSortBy, TIncludes>() Sets the .For<>() overload, the EntityControllerBase<> generics (Step 13), and which endpoints exist. Complex is required for typed sorting/includes and the batch POST /list & POST /search; GET /search (with count) exists for simple too. An attachment owner’s HasAttachments join entity is classified simple.
⚠️ Who writes it? one writer per save path A parent’s Related() sync and the child’s own IEntityService<T> coexist only while the parent’s input DTO leaves that collection null (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-level Related() 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 a Prepare hook 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 shared Attachment base that WithAttachments registers is free infrastructure — it costs nothing, however many owners use it. Write the tally as a comment block atop your Add{Entities}() extension:

Entity Classification Running tally
Category simple 1/5 simple
Product simple 2/5 simple
Order complex (typed TSortBy/TIncludes) 1/2 complex
OrderLine owned child via e.Related() — no slot —
Attachment shared base via WithAttachments — no slot —
ProductAttachment simple (HasAttachments join) 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 = free at Information on every start.

Step 0 overflow — what to actually do

⚠️ 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.


Entity Implementation Workflow

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.md before 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.

Minimal entity (happy path)

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:

  1. Entity — POCO implementing IEntityWithSerial (int key) or IEntity<TKey> (Step 1).
  2. DTOs — XDto (read) and XInputDto (write) (Step 5).
  3. Register — services.For<X>(e => { }); in a per-entity DI extension method (Steps 12 & 14).
  4. Controller — public class XController : EntityControllerBase<X, XDto, XInputDto>; (Step 13).
  5. DbContext — add public DbSet<X> Xs => Set<X>(); plus any relationship config (Step 11).
  6. Wire it up — call your extension method inside UseEntities<TContext>(…) (Step 14).
  7. Verify at runtime — run the app and walk the runtime checklist (Step 15); a green build proves almost nothing here.

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 → SearchResult with count. Both endpoints exist on simple and complex, so a simple entity can page too. Complexity buys typed sorting/includes and the batch POST /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.


Steps 1–5 — Model the entity: interfaces, SearchObject, enums, DTOs

The shape of the data and what the client may send. Classify first (§Step 0) — Steps 3–4 are what make an entity complex.

Step 1: Create Entity Model

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

IHasNormalizedContent is all-or-nothing. The global Q filter AND-s a NormalizedContent match for every entity that implements it, so declaring it but leaving NormalizedContent unpopulated (no [Normalized]/normalizer) makes Q match 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

Step 2: Create SearchObject

// 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 through ISearchObject<TKey> (the empty base slot) while model binding fills the shadowed one.

→ See: entities.examples.md — Category entity

Step 3: Create SortBy Enum

⚠️ Order via query.OrderOrThenBy(...) / OrderOrThenByDescending(...) (Regira.Entities.EFcore.Extensions) — they start the ordering or continue it with ThenBy as appropriate. Never branch on query is IOrderedQueryable<T> yourself: EF Core queries satisfy that check before any ordering is applied, so the intuitive guard compiles, passes DI validation, and throws Expression 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 TSortBy builder, which is invoked once per requested sort value and must therefore continue an order it may not have started. A simple registration’s e.SortBy(query => …) runs once for the whole query, so plain OrderBy/ThenBy is correct there — which is what the Category example uses.

→ See: entities.examples.md — Product entity

Step 4: Create Includes Enum

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 on Details only. Either way, gate each navigation behind the flag so List/Search stay lean.

Simple registrations can eager-load too — mind the asymmetry. Only the typed TIncludes overload is complex-only; every builder also has the untyped e.Includes((query, _) => query.Include(...)). Split by trap:

?includes= value contract. Entities on the generic EntityIncludes accept only Default/All; a specific member name (Categories, Parents, …) is accepted only by entities that expose a named [Flags] includes enum. The front-end scaffold’s --rel defaults to ["All"].

⚠️ One Includes(...) registration per entity — a second call replaces the first (last-write-wins). The builder registers a single IIncludableQueryBuilder, so DI resolves only the last registration: ❌ e.Includes((q, _) => q.Include(x => x.Owner)); e.Includes((q, i) => …) silently drops Owner from every response. Compose everything in one lambda — unconditional references first, flag-gated collections behind their checks. (Filters differ: every Filter(...)/AddFilter<>() registration runs.)

→ See: entities.examples.md — Order + OrderLine entities (typed) / Category & Supplier entities (untyped)

Step 5: Create DTOs

⚠️ Exclude a server-owned field and restore it in the same edit. A field absent from TInputDto maps as null/default on every PUT and PATCH — 200 OK, silent corruption (a status-only PATCH zeroes a computed Total). 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] (namespace Regira.Entities.Attributes) is enforced by a prepper UseDefaults() registers, so it guards the IEntityService write path and leaves a workflow service’s raw-DbContext write alone. It has no bypass: your own domain action saving through IEntityService is reverted like any client. Scalars and FKs only — a navigation and IArchivable.IsArchived are 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 Code or 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>; original is null on create, the stored row on update)
Stamp the field even when a raw-DbContext writer 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 (Status on 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


Steps 6–10 — Pipeline services: filters, processors, preppers, validators, primers, reactors, mapping

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.

Step 6: Create Query Builders

→ 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 override Build(...). Implement the IFilteredQueryBuilder<…> interface directly only when you want full control with no inherited behavior. Both live in Regira.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 Q filter. Keyword search on Q is already global for IHasNormalizedContent entities (UseDefaults() registers FilterHasNormalizedContentQueryBuilder); a per-entity Q filter 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.

SortBy lambda signature depends on the For<> overload:

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 no DbContext in it (Filter gets (query, searchObject), Includes gets (query, includes); only Prepare has 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 no e.Filter<TService>((q, so, svc) => …) overload. (new QKeywordHelper() inside a lambda compiles — its constructor parameters are optional — but it silently bypasses the app’s configured QKeywordHelperOptions and normalizer, so its keywords stop matching what was stored. Resolve the service, don’t re-new it.) The complete overload set per verb is in entities.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>() (no Add) does not exist — that name is the inline overload, so passing a type argument is CS0308. Compose all eager-loads in one Includes/AddIncludes per entity — a second registration replaces the first.

⚠️ Build is synchronous — IQueryable<TEntity> Build(IQueryable<TEntity>, TSearchObject?), no CancellationToken, 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 .Result inside Build deadlocks or serializes every request. Two sanctioned answers: express it as a subquery in the same IQueryable (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, see entities.blueprints.md → Recursive entities), or resolve the set before the request reaches the filter and pass it as an ordinary ICollection<TKey> on the SearchObject (?categoryId=1&categoryId=7&…), which keeps the API honest and puts the walk where the graph is already loaded.

Step 7: Processors — 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

Step 8: Preppers & Validators (Optional)

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:

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?):

Single-arg shortcut for int-keyed children: when the related entity has an int key, drop the second type argument — e.Related<TRelated>(x => x.Collection, …). This shortcut is available on all int-key builders, including the simple For<TEntity, int, TSearchObject>() registration. The two-arg form e.Related<TRelated, TRelatedKey>(…) is only required for non-int related keys (type inference can’t deduce TRelatedKey from 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.

Validators — refusing a write

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>:

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

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 Patterns — Decision Table

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 (or IEntity<TKey>) — it skips only its own .For<>() registration, not the entity interface. e.Related<TRelated>() constrains TRelated : IEntity<int>.

⚠️ One writer per save path. A parent’s Related() sync and the child’s own IEntityService<T> may coexist — the registrations don’t conflict — but only while the parent’s input DTO leaves the collection null, 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 in entities.patterns.md — Single-field PATCH / state toggle; just keep the collection off the parent’s TInputDto. Unless the child is also sortable: SortOrder is collection-level and must ride the parent DTO, so guard the per-row field with a Prepare hook instead (same file → Owned children that are both sortable and individually togglable).

⚠️ Calling Modify from your own action? null = untouched is the sync’s contract, and EF gets there first. On a tracked parent loaded with Include(x => x.Items), setting Items = null to no-op the sync orphans the children before the sync is ever consulted — they cascade away on SaveChanges, 200, no error. Hand Modify a detached instance (AsNoTracking(), or new Cart { Id = id, …, Items = null }); [ServerOwned] restores what you left off.

Related() children cost no budget slot. A child collection managed via e.Related(...) gets no .For<>() and no controller — it rides on the parent’s endpoints. Giving OrderLine its 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 OnDelete intent per FK. Choose the behavior deliberately in OnModelCreating — Cascade to remove dependents with the parent, or Restrict so 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 a Restrict FK refuses (§Step 8 → Validators).

⚠️ A join entity has two FKs and they usually want different behaviour. On the owner side (ProductCategory.Product) Cascade is almost always right — the join rows are part of the product. On the lookup side (ProductCategory.Category) Cascade means 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. Prefer Restrict there when you want the delete refused while the lookup is in use. The ProductCategory example in entities.examples.md cascades 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.Sqlite leaves PRAGMA foreign_keys disabled unless the connection string says Foreign Keys=True, so Restrict never fires and the delete succeeds silently — see entities.setup.md → P3 for the connection string.

Step 9: Primers (Optional)

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 the IEntityService write path. So a primer that restores a server-owned field from entry.OriginalValues also fires on — and reverts — a domain/workflow service’s raw-DbContext write. 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), or EntityPrepperBase<T> when you need the full stored row — not a primer. The choice table is in entities.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

Reactors — after the commit

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>:

How reactors run:

→ See: entities.examples.md — Additional Patterns > Reactors

Step 10: Mapping & AfterMappers (Optional extra configuration)

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.

⚠️ 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 PersonCoreDto carried by every TicketDto cannot 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 /people and 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. Check e.Includes(...) before adding any AddMapping.

→ See: entities.examples.md — Additional Patterns > Mapping / AfterMapper


Steps 11–15 — Register, expose and verify

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.

Step 11: Update DbContext

Add DbSet<YourEntity> and configure any relationships in OnModelCreating.

Any IArchivable entity? 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 everything OnModelCreating configured. ⚠️ Two cases still need a call: a DbContext you 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 of DbContextWiring.ArchivedQueryFilter ends OnModelCreating with modelBuilder.SetArchivedQueryFilter() (Regira.Entities.EFcore.Extensions) instead — after your own HasQueryFilter(...) 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 one PossibleIncorrectRequiredNavigationWithQueryFilterInteractionWarning per relationship whose required principal is IArchivable — “Entity ‘Order’ has a global query filter defined and is the required end of a relationship with ‘OrderLine’“. On net8.0 it 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. For Order → OrderLine that is the intent. For a Category that fifty separately-registered Asset rows point at, archiving one silently removes those fifty from every list while /search keeps reporting them: short pages, no error, nothing logged. Reference data behind a required FK should not be IArchivable — use a real DELETE with OnDelete(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 parent Yes — otherwise its count and items disagree. No ?archived= opt-in lifts it (the opt-in acts on the queried IArchivable entity 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 Delete
is 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 care

It never collides with the named archived filter, because that one only touches IArchivable types.

⚠️ 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 the APPLY. So after adding one, re-run the endpoints that filter the parent by Any(...) 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

Step 12: Configure Entities

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)

Step 13: Configure Web Endpoints

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 an IGlobalFilteredQueryBuilder would 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.

EntityControllerBase lives in Regira.Entities.Web.Controllers.Abstractions. A bare using Regira.Entities.Web.Controllers; is not enough — the base class is in the .Abstractions child namespace and the controller won’t resolve (CS0246) without it. See get_package(id: "Regira.Entities.Web", section: "entities.web.namespaces") for the exact using set (this guide ships with Regira.Entities.Web and is served over MCP only — it does not extract locally).

Keep controller routes resource-relative and spell the resource — [Route("products")] — then apply a shared api base 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 a SupplierController serves /Supplier, not the kebab-case plural /suppliers the SPA calls — a 404 on every request for that entity. See entities.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 optional TDto and TInputDto). A wrong count compiles but fails at DI startup validation. Controllers resolve IEntityService<> from HttpContext.RequestServices at 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, use IEntityService<TEntity, TKey>.

Bare IEntityService<TEntity> shortcut (no TKey) 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 expose IEntityService<TEntity, int> (and the fully-typed interface), not the bare shortcut. Attempting to resolve IEntityService<TEntity> there throws No 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 carries duration in ms). Unwrap item/items client-side.

PATCH vs PUT: Use PUT when the client has the full entity. Use PATCH when only a subset of fields should change. The PATCH implementation deserializes the incoming JSON Merge Patch into TInputDto, so only fields declared on the input model can be modified. TInputDto property names must match TEntity property 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 on TInputDto but omitted from a PATCH body is preserved too — Merge Patch writes only the keys you send. The merged input is validated against TInputDto’s DataAnnotations first: a failure answers 400 with model binding’s ValidationProblemDetails, without errorDetails. The trap is the opposite case: a field the DTO never declares maps as null/default on every PATCH and PUT — see ⚠️ below.

⚠️ A field absent from TInputDto maps as null/default on PATCH and PUT. Server-owned/immutable values (OwnerId FKs, generated codes, computed totals) silently reset — a [Required] column 500s, a computed Total zeroes. Mark each one [ServerOwned] (the Step 5 recipe; the cases needing a prepper or a primer instead are in entities.patterns.md → Server-owned / immutable fields on update). Synced collections invert the failure: an Attachments (or Related()) collection the DTO never declares maps as null, which the sync reads as “not sent” — edits are ignored, not reset. Silent either way; declare the collection on the DTO (§Attachments step 3).

Step 14: Setup and add Entity services to DI

→ See: entities.setup.md — P4: Create the DI Extension Method

Step 15: Verify at runtime (required)

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:

  1. The app starts without a 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.
  2. 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).
  3. 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.
  4. 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.


Seeding via IEntityService

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.


Custom Entity Services

Using EntityWrappingServiceBase

Who 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:

Examples:

Registration:

→ See: entities.signatures.md — EntityWrappingServiceBase → See: entities.examples.md — Order + OrderLine entities (OrderManager)

Replacing the repository app-wide

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

Seeing every read and write once

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


Global Services

Global Services (→ see entities.examples.md)

UseDefaults() — What It Registers

options.UseDefaults() is a convenience method that registers, in one call:

Hiding archived rows is part of UseDefaults() (DbContextWiring.ArchivedQueryFilter): the e => !e.IsArchived EF 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 the DbContext. 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 with DefaultArchivedFilter = ArchivedFilter.Included on UseEntities(). ⚠️ The wiring reaches contexts resolved from DI only: a hand-constructed new 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.

→ See: §Quick Reference: Built-in Services (this file) for the full list of registered classes.


Startup validation (Development)

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:

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.


Paging defaults

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

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

Normalizing

The normalize contract

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

Custom Normalizer

→ See: entities.examples.md — Additional Patterns > Global normalizer

Filtering with Normalized Content and IQKeywordHelper

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 QKeyword carries the term twice: the Trimmed* 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 the Trimmed* ones against a column that stores the client’s value verbatim, such as an attachment FileName or a reference code. Crossing them compiles and returns nothing, with no error: the default normalizer drops . and turns - into a space, and preserves case — Transform defaults to NoChanges — so my-report.pdf is looked up as my reportpdf.

⚠️ The Trimmed* members hold the raw input, so a % or _ the client typed keeps its SQL LIKE meaning. On the attachments endpoint — the Trimmed* caller — ?fileName=my_report* also matches my-report.pdf, and ?fileName=50%* matches every name starting 50. 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 the IHasNormalizedContent path the term reaches QW through the normalizer, whose allow-list ([^a-z0-9\s\-_,!;&']) deletes a typed % — but _ is in that set and survives, so ?q=my_report still searches %my_report% and the underscore keeps its single-character LIKE meaning. On the explicit-fields FilterQ path nothing is deleted: it matches TrimmedQW, 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 Q search, fold the related data into NormalizedContent — don’t add a second Q filter. The global normalized-content filter already AND-s a Q predicate for every IHasNormalizedContent entity, so a second, custom Q filter is AND-ed on top of it and silently narrows (or empties) the result. The idiomatic fix is to make the parent’s NormalizedContent already contain the searchable text from the related entities — register a custom EntityNormalizer (or use [Normalized(SourceProperties = [...])]) that includes those properties — so the single global filter covers everything.

→ See: entities.examples.md — Additional Patterns > IQKeywordHelper — Q full-text search

Enable Normalizer Interceptors

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


Attachments

→ See: entities.examples.md — Additional Patterns > Attachments → See: entities.signatures.md — Attachments → See: io.storage.instructions — the IFileService file-store contract behind attachments (read before wiring a store). get_package(id: "Regira.IO.Storage", section: "io.storage.instructions"), or io.storage.instructions.md locally.

  1. Create a class inheriting the 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.
  2. Implement 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();
    }
    
  3. Owner with its own input DTO (on the controller or through 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>?.
  4. Create a controller inheriting 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, ….
  5. Add 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);
    
  6. Register two things: .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.
  7. (web apps) Call 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 PUT of the owner whose input carries Attachments (step 3) syncs the links itself: it adds one for each new entry with NewBytes, renames and replaces a kept link’s file from its NewFileName and NewBytes, and deletes the ones the array leaves out, and only the owner’s validators run. A kept link keeps its attachment, whatever attachmentId the entry sends, and a new one may point only at an attachment the owner already links: one naming another owner’s is cleared, and without NewBytes of its own the save answers 409. The upload route always creates a link, whatever Id its form sends. Repeat a link rule — allowed file types, a file that must not be deleted — in the owner’s validator (keys like Attachments[0].NewFileName), or keep Attachments off 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 DELETE answers 500. Use SortOrder or a flag on the link entity; startup validation warns, and entities.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 in entities.patterns.md → Soft Delete > Attachments on an archivable owner.

Reads: eager-load the owner’s Attachments, or the file metadata comes back null. HasAttachments wires 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 with e.Includes((q, _) => q.IncludeEntityAttachments()) (Regira.Entities.EFcore.Attachments), i.e. .Include(x => x.Attachments!).ThenInclude(a => a.Attachment). Including only x.Attachments loads the join rows but leaves attachment null on the DTO — so fileName, contentType and length are all missing. uri still resolves: with no Attachment.FileName to build the filename route from, the resolver falls back to the by-id files/{id} link.

⚠️ The link DTO nests the file. id, objectId, attachmentId, objectType, sortOrder and uri are flat; fileName, contentType, length and the timestamps live one level down under attachment (EntityAttachmentDto.Attachment, an AttachmentDto). Reading link.fileName yields undefined, 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 the OrderBy. HasAttachments assigns SortOrder from 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)).

⚠️ HasAttachment is a property nothing fills and a filter nothing binds — both halves are yours. Reads: it serializes null unless you set it on the read path (an IEntityProcessor, 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: declare bool? HasAttachment on your TSearchObject and apply query.FilterHasAttachment(so.HasAttachment) (Regira.Entities.EFcore.Extensions — it queries Attachments.Any(), not the property) in the query builder. Without the SearchObject property, ?hasAttachment=true binds 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 Uri is null, never an error. All four causes: the option was omitted, or set on a different UseEntities options instance than the one the entity was registered on (both leave the NullAttachmentUriResolver in 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.

FileName carries the client’s virtual folders; storage never does. FileName is 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 different FileName: 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 call GetBytes(item). Consuming code references files by Identifier (the public storage key, populated when you load through the entity service); Path is internal and isn’t mapped to DTOs — clients get a download Uri instead.

Ordering & extending the DTOs. Attachment order travels by array position, not by a client-sent value: on every parent save the pipeline assigns SortOrder = index over the incoming collection (SetSortOrder(), wired by HasAttachments), so the input DTO carries no SortOrder on purpose. EntityAttachment and the read EntityAttachmentDto expose it — order the eager-load (x.Attachments!.OrderBy(a => a.SortOrder)) so a round-trip is stable. The DTOs are records, so a derived DTO must also be declared record (CS8865). Extra client-supplied fields belong on a derived input DTO; a derived read DTO works too (the Uri after-mapper matches attachments by the EntityAttachmentDto base type).

Images in <img> tags 401 on a secured API — the browser sends no Authorization header for src URLs. 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.


Error Handling

EntityInputException (returns HTTP 400)

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 an EntityInputException<Product> thrown by a prepper during an Order write escapes them; the base is what the filter matches, and what your own catch should. Worked example: entities.patterns.md § Domain actions on an entity resource.

→ See: entities.examples.md — Order + OrderLine entities (the order-lines validator in AddOrders)

EntityConstraintException (returns HTTP 409)

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.

EntityConcurrencyException (returns HTTP 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.


Common Patterns

Master-Detail (Order + OrderItems)

→ See: entities.examples.md — Order + OrderLine entities

Many-to-Many Relations

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().

→ See: entities.examples.md — Product entity

Feature recipes → entities.patterns.md

Load that file when implementing one of these:

Domain blueprints → entities.blueprints.md

Ready-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:


Response Types

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


Quick Reference: Built-in Entity Interfaces

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/EndDate are nullable (implementing them with non-nullable DateTime fails CS0738), 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 to IHasStartEndDate), which matches rows whose period contains date, treating either bound as open when null. If your period is mandatory, plain non-nullable Start/End properties of your own are the better fit — and then the range filter is yours to write.

Nullability convention: IHasCode/IHasTitle/IHasDescription/IHasNormalizedContent declare their strings as string? — a contract convention, not enforcement. Need non-null? Add [Required] on the implementing property; the string? declaration stays — ❌ public string Code { get; set; } = null!; narrows the interface setter and warns CS8767, ✅ [Required] public string? Code { get; set; }.

Quick Reference: Built-in Services

Registered by options.UseDefaults(); can also be registered manually.

Global Filter Query Builders

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

Primers

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]

Normalizer Services

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

Security & Authorization

Generated endpoints ship anonymous — no controller base carries [Authorize]. Every scaffolded endpoint, including delete and attachment download, is public until the app adds authorization.


Troubleshooting

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.

Troubleshooting — reads: data missing from responses

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

Troubleshooting — writes: not persisting, or silently overwritten

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

Troubleshooting — compiler errors

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

Troubleshooting — startup, DI and registration

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

Troubleshooting — packages and provider versions

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

See Also