Regira-Packages

Regira Entities — Feature Patterns

Per-feature recipes, loaded on demand. The always-loaded spine (entities.instructions.md) keeps only a one-line trigger per pattern; the detail lives here. Core modeling patterns (Master-Detail, Many-to-Many) and the relationship decision table stay in the spine.

Bulk insert / update (batching through IEntityService)

The write service exposes single-item Add, Modify, Save, and Remove plus a single SaveChanges() (IEntityWriteService<TEntity, TKey>). Those per-item calls only track the change — no INSERT or UPDATE is issued until SaveChanges(). To insert or update many rows, loop the per-item calls and flush once:

// Resolve the always-registered IEntityService<TEntity, TKey> outside a controller (see §Step 13).
var service = scope.ServiceProvider.GetRequiredService<IEntityService<Product, int>>();

foreach (var product in products)
    await service.Add(product);   // tracks only — no DB round-trip yet

await service.SaveChanges();      // one round-trip flushes the whole batch

⚠️ Writes batch; preppers and validators do not. Add() issues no round-trip while nothing is registered against the entity — a prepper or validator that queries the DbContext (an existence check, a price lookup, a per-row FindAsync) runs inside the loop, once per item. A 500-row seed wave against an entity with a two-query prepper issues ~1,000 round-trips before the single flush, and nothing in the code shape shows it. Hoist the lookup into a dictionary built once before the loop.

Two timing facts drive how you order a bulk run:

Multi-wave seeding

Applies the first fact directly: save parents, then assign their generated Ids to children.

// Wave 1 — parents
foreach (var c in rootCategories) await categories.Add(c);
await categories.SaveChanges();                 // Ids now populated; tracker cleared

// Wave 2 — children referencing wave-1 Ids
foreach (var (parent, child) in childPairs) child.ParentId = parent.Id;
foreach (var child in childCategories) await categories.Add(child);
await categories.SaveChanges();

Seeding owned / join rows (m2m, hierarchy joins)

A collection managed by e.Related() is owned and has no IEntityService<T> of its own (see the relationship decision table), so it can’t go through the service loop above. Two ways in:

Prefer the parent-navigation form when the join hangs off an entity you’re already saving; reach for the DbContext only for joins with no owning write path.

You own de-duplication on the DbContext path. Nothing dedupes rows you Add directly — if the join has a unique index (e.g. (ParentId, ChildId)), a repeated edge throws UNIQUE constraint failed on SaveChanges(). Distinct() your edge list (or skip pairs already present) before adding.

Creating attachments in code

Resolve the per-owner link service registered by HasAttachments — IEntityService<TEntityAttachment, int> — and add a link that carries a nested Attachment with the bytes. The attachment write pipeline (bytes→file conversion + storage-key Identifier generation) runs on SaveChanges(), writing the file, filling Path/Length, and assigning AttachmentId:

// IEntityService<ProductAttachment, int> — registered by HasAttachments.
// Not IAttachmentService<…>: that interface is for the shared Attachment base only, and a link
// entity is an IEntityAttachment (not an IAttachment), so the typed overload can't bind to it.
var links = scope.ServiceProvider.GetRequiredService<IEntityService<ProductAttachment, int>>();

foreach (var product in products)
    await links.Add(new ProductAttachment
    {
        ObjectId = product.Id,
        Attachment = new Attachment { FileName = "spec.pdf", Bytes = bytes }
        // (set Attachment.Identifier to pick the storage key; otherwise it is derived from FileName)
    });

await links.SaveChanges();   // one flush; pipeline writes files, fills Path/Length, assigns AttachmentId

In-code recipes (how_to)

Task-oriented answers for the in-code jobs that don’t map onto a single type — the source of the MCP how_to tool. Each recipe below carries a <!-- how_to: key=… aliases=… --> marker; the knowledge-base builder parses these markers and the body under them (up to the next heading) into the how_to tool’s recipe set, so the tool and this guide never drift. To add or edit a recipe, edit the body here.

Create an attachment in code (seeding / import)

Resolve the per-owner link service registered by HasAttachments — IEntityService<TEntityAttachment, int> — and add a link carrying a nested Attachment with the bytes. The attachment write pipeline runs on SaveChanges(): it writes the file, fills Path/Length, and assigns AttachmentId.

// Registered by HasAttachments. NOT IAttachmentService<…>: that interface is for the shared
// Attachment base only, and a link entity is an IEntityAttachment (not an IAttachment).
var links = sp.GetRequiredService<IEntityService<ProductAttachment, int>>();

await links.Add(new ProductAttachment
{
    ObjectId = product.Id,
    Attachment = new Attachment { FileName = "spec.pdf", Bytes = bytes }
});
await links.SaveChanges(); // pipeline writes the file, fills Path/Length, assigns AttachmentId

See: get_package(id: "Regira.Entities", section: "patterns", heading: "Bulk insert / update") and get_package(id: "Regira.Entities", section: "examples", heading: "Attachments").

Put the marker on the child — a flag, or a rank column it already has (an attachment link entity’s SortOrder, assigned from the incoming array position). A foreign key from the owner to one of its own child rows makes the two tables reference each other: SQL Server refuses the migration (Msg 1785) and every owner DELETE answers 500 with EF’s circular dependency, which no primer can fix — EF orders deletes from the original foreign-key values. If the reference has to stay, map it DeleteBehavior.ClientSetNull and drop it in a save of its own.

See: get_package(id: "Regira.Entities", section: "patterns", heading: "An entity that references one of its own children").

Bulk insert / seed many rows

There is no AddRange. The per-item Add/Modify/Save/Remove calls only track changes; nothing hits the database until a single SaveChanges(). Loop the per-item calls and flush once:

var service = sp.GetRequiredService<IEntityService<Product, int>>();
foreach (var product in products)
    await service.Add(product);   // tracks only — no DB round-trip yet
await service.SaveChanges();       // one round-trip flushes the whole batch

See: get_package(id: "Regira.Entities", section: "patterns", heading: "Bulk insert / update").

Back-date Created / LastModified when seeding

Pre-set timestamps survive — no raw SQL needed. HasCreatedDbPrimer stamps Created only when it is DateTime.MinValue, and HasLastModifiedDbPrimer stamps only on update. Assign historical dates before the first SaveChanges():

await service.Add(new Ticket { /* … */ Created = new DateTime(2024, 3, 1) });
await service.SaveChanges(); // the pre-set Created is kept; the primer does not overwrite it

⚠️ Your own preppers and primers must follow the same convention — stamp only when the value is absent (default/DateTime.MinValue/null). The built-ins protect Created/LastModified only; an unconditional SubmittedAt = DateTime.UtcNow on create silently flattens the seeded timeline to seed time.

// SubmittedAt is set once, when the record is created — so stamp on create, restore on update.
if (original == null && modified.SubmittedAt == default) modified.SubmittedAt = DateTime.UtcNow;
if (original != null) modified.SubmittedAt = original.SubmittedAt;

A field set by a later event (ClosedAt, ApprovedAt) is not this shape — it stays null until the transition writes it, so guard it on the transition instead of on create (§Role-gated transitions).

See: get_package(id: "Regira.Entities", section: "patterns", heading: "Bulk insert / update").

Workflow / state transitions

A state change (submit → approve → close) is a domain action: a second controller on the entity’s route (POST {id}/approve) that loads with Details(id), decides, writes through IEntityService and answers with a re-read in the generated endpoints’ shape:

item.Status = RequestStatus.Approved;
await service.Modify(item);
await service.SaveChanges();
return await this.Details<CreditRequest, CreditRequestDto>(id) ?? NotFound();   // fresh navigations, the DTO, { "item": … }

See: get_package(id: "Regira.Entities", section: "patterns", heading: "Domain actions on an entity resource") — Role-gated transitions is its sub-section.

React to a committed change (status pipelines, mail, jobs)

What must follow a change — mail the customer, start a follow-up workflow, enqueue a background job — is a reactor. It runs once the save is committed (never for a failed save or a rolled-back transaction), on every write path that reaches the row — CRUD PATCH, a domain action, an import, a raw DbContext writer:

e.React(x => x.Status, OrderStatus.Shipped, (change, services, token) =>
{
    // hand the work to a job system; the reactor itself runs inside the call that commits —
    // SaveChanges(), or Commit() when the save ran inside a transaction
    services.GetRequiredService<IBackgroundJobClient>().Enqueue<IOrderMailer>(m => m.SendShipped(change.Entity.Id, CancellationToken.None));
    return Task.CompletedTask;
});

change.Original holds the stored row beside the committed change.Entity; a reactor runs in a DI scope of its own, and one that throws is logged while the save stands. Don’t put this after SaveChanges() in a controller action: the other paths to the same state would skip it.

See: get_package(id: "Regira.Entities", section: "instructions", heading: "Reactors — after the commit").

Which service is registered for an entity (incl. attachments)

Every entity registered with For<>() resolves as IEntityService<TEntity, TKey> (and the search-object / read / write / repository variants). Attachments specifically:

IAttachmentService / IAttachmentService<…> are not registered for link entities — a link is an IEntityAttachment, not an IAttachment, so that typed overload can’t bind to it. Use IEntityService<…>.

See: get_package(id: "Regira.Entities", section: "examples", heading: "Attachments").

Sync nested owned collections (children of children)

An owned child collection whose rows own a collection of their own (order → lines → line discounts; party → relationships → relationship contact data) syncs in one registration — the configure callback of Related() nests another Related():

e.Related(
    item => item.ChildRelationships, item => item.ChildRelationships?.Prepare(),   // level 1: parent-level prepare
    rel => rel.Related(r => r.ContactData, r => r.ContactData?.Prepare())          // level 2: per-row nested sync
);

The nested sync runs per level-1 row, matched to its original by Id, and diffs that row’s sub-collection the same way the outer sync diffs level 1 (rows of a new level-1 item are all tracked as added). Deeper levels nest the same way. Neither level needs its own .For<>(), controller, budget slot, or a hand-written prepper — write a prepper only for logic beyond collection syncing.

See: get_package(id: "Regira.Entities", section: "blueprints", heading: "Stakeholders") for the full worked model, and §Step 8 in the instructions for the Related() signature.

Archived lookup drops referencing rows from lists

Symptom: GET /products returns fewer items than pageSize while /products/search still counts them — 200, no error. Sorted by a column that interleaves parents it loses about one row per page and reads as a paging off-by-one; it is not. Cause: Product.CategoryId is required and Category is IArchivable. On net10.0 the archived filter propagates into the Include as an inner join, so a product whose category is archived drops out of items; the count query has no join. Pick by what the principal is:

// 1. Reference data is never archivable: drop IArchivable from Category and refuse the delete while in use
modelBuilder.Entity<Product>().HasOne(x => x.Category).WithMany().OnDelete(DeleteBehavior.Restrict);

// 2. Or make the relation optional, so an archived category leaves the product visible
public int? CategoryId { get; set; }
public Category? Category { get; set; }

// 3. An aggregate parent (Order → OrderLine) whose dependent you also query directly: mirror the filter
modelBuilder.Entity<OrderLine>().HasQueryFilter(x => !x.Order!.IsArchived);

Startup validation (Development) warns naming both entities, and EF’s PossibleIncorrectRequiredNavigationWithQueryFilterInteractionWarning lists every affected pair — treat that list as the checklist, not as noise.

See: get_package(id: "Regira.Entities", section: "patterns", heading: "Soft Delete") — Before you make reference data IArchivable.

?q= search returns nothing (or everything)

Two silent shapes, one fix. Every ?q= request returns an empty list — 200, no error, no warning: the entity implements IHasNormalizedContent but nothing fills NormalizedContent ([Normalized] on the class instead of the property, or no attribute at all), so the global Q filter ANDs a match against null. ?q= returns everything, unfiltered: the entity does not implement the interface and has no custom filter — startup logs “?q= text search is silently ignored for: X”. Either way the attribute goes on the property, naming its sources:

public class Article : IEntity<int>, IHasNormalizedContent
{
    [MaxLength(1024), Normalized(SourceProperties = [nameof(Title), nameof(Description)])]
    public string? NormalizedContent { get; set; }
}

Rows saved before the attribute existed stay empty until re-saved (or seeded through IEntityService). Don’t add a per-entity Q filter on top — it ANDs with the global one and narrows or empties results.

See: get_package(id: "Regira.Entities", section: "instructions", heading: "Normalizing") and §Step 1 (IHasNormalizedContent is all-or-nothing).

Owned collection wiped by a partial save

Symptom: a PATCH or save that only touched Status left the parent with zero owned rows. Cause: the collection reached the Related() sync as []. The contract is null = leave the rows alone, [] = delete them all — and an input DTO that initializes the collection turns every omitted collection into []:

public class OrderInputDto
{
    public string? Status { get; set; }
    public ICollection<OrderItemInputDto>? Items { get; set; }   // nullable, no "= []": null when not sent
}

A client that is not editing the rows omits the property (or sends null); a hand-written call must never default it to an empty array. A computed total in a prepper needs the same branch — null means re-read the persisted children, not “sum nothing” — and so does a rule spanning a scalar and the rows: a PATCH of the scalar alone arrives with the collection null, so a check that runs only when rows are sent is bypassable.

See: get_package(id: "Regira.Entities", section: "instructions", heading: "Relationship Patterns — Decision Table") — One writer per save path — and §Step 5.

Soft Delete

Opt-in, and often the wrong default. Soft delete takes a large share of this guide’s warning budget, which can read as “every deletable entity should be IArchivable”. It should not. No IArchivable entity means no archived query filter, no ?archived= machinery, and none of the hazards below apply — a plain DELETE is the simpler contract. Reach for it when the row’s history must survive its removal (an Asset, an Order), not for reference data (see the decision table below) and not for “an employee left” — a plain IsActive flag keeps them out of pickers without pulling their rows out of the audit trail.

One thing to write: implement IArchivable on the entity. UseEntities<TContext>(e => e.UseDefaults()) supplies the rest — ArchivablePrimer, FilterArchivablesQueryBuilder, and the e => !e.IsArchived EF query filter itself, wired into the context’s options (DbContextWiring.ArchivedQueryFilter) and applied at model finalization. The DbContext needs no Regira call.

The filter lands on every IArchivable entity type — root types only, since EF takes a query filter on the root of a hierarchy (covering the derived types) and an owned type is configured through its owner. Being a real EF filter it also propagates into Include(...). It is the only thing that excludes archived rows: the query builder translates the ?archived= opt-ins alone, Excluded composes nothing. Archived filtering is therefore EF-only — on a non-EF IQueryable, Excluded filters nothing.

⚠️ A DbContext you construct yourself never sees that wiring. new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()…Options) — integration tests, an IDesignTimeDbContextFactory, a seeding tool — builds its model without consulting the service collection, so archived rows stay visible there while the host hides them. Add the filter to those options explicitly:

new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
    .UseSqlite(connection)
    .AddArchivedQueryFilter()          // Regira.Entities.EFcore.Extensions
    .Options);

Wiring it in the DbContext instead is still supported — for a setup that opted out of DbContextWiring.ArchivedQueryFilter, or one that prefers the model to state it. It must come after your own HasQueryFilter(...) calls and only once; calling it while the automatic wiring is on is harmless (the convention skips an entity type that already carries the filter):

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);
    // entity configuration — including any HasQueryFilter of your own — goes above
    modelBuilder.SetArchivedQueryFilter();   // Regira.Entities.EFcore.Extensions — last, exactly once
}

⚠️ A model that ends up with no archived filter leaves archived rows visible everywhere — lists, GET /{id}, included collections — while DELETE keeps flagging rows nobody hides. Startup catches it: ArchivedQueryFilterValidator raises a validation error naming the entity, which stops the host in Development. It inspects the built model, so it reports the outcome regardless of which route was supposed to install the filter.

⚠️ Before you make reference data IArchivable — read this

EF logs one PossibleIncorrectRequiredNavigationWithQueryFilterInteractionWarning per relationship whose required principal is IArchivable — “Entity ‘Employee’ has a global query filter defined and is the required end of a relationship with ‘CreditAllocation’“. Whether that warning is noise or a data bug depends on one question: is the principal an aggregate parent, or reference data?

The principal is… Archiving one means Do
an aggregate parent — Order → OrderLine, an e.Related() child with no For<>() of its own its children go with it, which is the intent suppress the warning, and read on for the dependent-you-query-directly case
an aggregate parent whose children are separately registered — Asset → MaintenanceRecord with its own .For<>() and list endpoint the children stay stored, but every read of theirs that joins the parent loses them — and the mirrored filter below hides them outright, even from ?archived=included usually a domain state instead (Retired, Cancelled) with a real DELETE and Restrict, so the history stays readable. Archive it only when hiding the children is the intent (mirror, below), or keep the parent out of their reads (below)
reference data — a category, a status, a type, a lookup any separately-registered entity points at every row referencing it silently vanishes from list results do not make it IArchivable. Delete it for real and let OnDelete(Restrict) return 409 while it is in use. Or make the FK optional, so the dependent survives with a null navigation

What “silently vanishes” means, precisely. The archived filter is a real EF filter on the principal, so it propagates into Include(...); where the navigation is required EF composes it as an inner join and the dependent rows drop out of the items projection. The count query carries no includes, hence no join, hence no elimination. So /search reports a total its own page does not contain:

baseline                                 count 495 | items 100   (pageSize=100)
after archiving one of 12 categories     count 495 | items  94   ← short page, no error
scoped to that category                  count  43 | items   0   ← 43 rows nobody can reach

Startup validation reports this shape (ArchivableReferenceDataValidator, a warning naming both entities) — it fires only for a dependent that is separately registered, since an e.Related() child is never queried on its own.

If it really is an aggregate parent, suppress the warning per context with .ConfigureWarnings(w => w.Ignore(CoreEventId.PossibleIncorrectRequiredNavigationWithQueryFilterInteractionWarning)) on the AddDbContext options. And for any dependent you also query directly, mirror the filter — modelBuilder.Entity<OrderLine>().HasQueryFilter(x => !x.Order!.IsArchived) — which is what keeps its count and items in agreement. A filter you add to a dependent that is not itself IArchivable never collides with the named archived filter: the archived filter only touches IArchivable types, so your filter on such a dependent is unopposed.

What mirroring costs. ?archived= opts in on the queried entity only, and a mirrored filter is your own filter on a dependent that is not IArchivable — no opt-in ever lifts it. An archived parent’s children are then unreachable through the API for everyone, the administrator who archived it included. When they must stay readable, don’t mirror: keep the parent out of the children’s reads — no Include of it, no filter through its navigation, and the one field their list shows denormalised onto them (AssetCode on the record) — so no join exists for the archived filter to drop rows through. Startup validation still warns, since it inspects the model’s shape rather than your queries; with no read joining the parent, that warning stands by design.

Attachments on an archivable owner

The mirrored filter above needs a navigation on the dependent to bind to (x.Order), and a per-owner attachment link has none: EntityAttachment carries ObjectId / ObjectType only, and the model configuration in entities.examples.md → Attachments maps it with a bare WithOne(). So on an IArchivable owner the validator’s remedy does not compile until you add the navigation yourself. This is a common intersection, not an edge case: soft delete belongs on aggregate parents, which are exactly the entities that own files, and the per-owner link is always separately registered (it costs its own simple slot).

Three edits, in this order:

public class TicketAttachment : EntityAttachment
{
    public TicketAttachment() => ObjectType = nameof(Ticket);
    public Ticket? Ticket { get; set; }          // exists so the archived filter has something to bind to
}
modelBuilder.Entity<Ticket>(e =>
    e.HasMany(x => x.Attachments).WithOne(a => a.Ticket!)      // WithOne names the navigation the filter binds to
        .HasForeignKey(x => x.ObjectId).HasPrincipalKey(x => x.Id));

modelBuilder.Entity<TicketAttachment>(e =>
{
    e.HasOne(x => x.Attachment).WithMany().HasForeignKey(x => x.AttachmentId);
    e.HasQueryFilter(x => !x.Ticket!.IsArchived);             // your own filter — nothing to order it against
});

The link is not itself IArchivable, so this filter never collides with the named archived filter, and /tickets/{id}/attachments then agrees with the parent: archiving the ticket hides its files from both count and items.

Your own HasQueryFilter(...) on an IArchivable entity keeps applying. The archived filter is a named filter ("Regira:Archived"), and EF Core 10 refuses to build a model that mixes anonymous and named filters — so an anonymous filter of yours on the same entity is re-registered under "Regira:Model". It runs exactly as before and, being named, survives the ?archived= opt-ins, which drop "Regira:Archived" and nothing else. The wired convention does this at model finalization, after everything OnModelCreating configured, so ordering is not yours to get right. It is yours to get right when you call SetArchivedQueryFilter() in the DbContext: call it before your own anonymous filter and the later one sits unnamed beside the named one, and the model fails to build.

Route Behaviour
DELETE /{id} soft-delete — sets IsArchived = true and writes nothing else of the row beyond what the primers stamp (LastModified, a new token), so the delete-by-key stub Remove(new Order { Id = id }) archives without touching the stored values; the rows that depend on it stay as they are, loaded or not (EF’s cascade to loaded ones is undone, so no child is deleted, archived or orphaned); the row survives, real affected count, idempotent
GET /, GET /search archived excluded by default; ?archived=only → the recycle bin; ?archived=included → both
GET /{id} 404 for an archived row
GET /{id}?archived=included resolves the archived row
write path (PUT / PATCH / POST /save / the DELETE lookup) always archived-inclusive, so restore works with no client change
IsArchived on TInputDto keep it — generated forms hide it and nothing excludes it. Absent from the DTO preserves the persisted value; an explicit false un-archives
included collections archived children are excluded too — the filter is an EF query filter, so it propagates into every Include() (net10.0; see the net8.0 note below)
custom read service (UseReadService<>/HasRepository<>) ⚠️ override both Details(id, ct) and Details(id, archived, ct) — the second is a default interface member, so inheriting it leaves the write path unable to see archived rows and restore 404s

One knob drives the read side: ISearchObject.Archived (ArchivedFilter?), bound from ?archived=.

Archived Rows returned
Excluded non-archived only
Included both
Only archived only — the recycle bin
null falls back to EntityQueryOptions.DefaultArchivedFilter (Excluded), settable on UseEntities()

On net10.0 the two opt-ins suspend the archived filter by name and nothing else: a HasQueryFilter(...) of your own keeps applying, as does every IGlobalFilteredQueryBuilder. Row security is never widened by ?archived=.

?archived= binds case-insensitively (standard ASP.NET Core enum binding), so ?archived=included, included, Included and INCLUDED all resolve — the lowercase spelling used throughout these guides matches the front-end enum member, and the C# member is PascalCase.

⚠️ On net8.0 (EF Core 9) no archived query filter is installed at all — neither route does anything there. EF Core 9 has no named query filters, and honouring the opt-ins would take the untargeted IgnoreQueryFilters(), which suspends your query filters too (and the write path resolves its row archived-inclusive on every update, so that would be a cross-tenant write). Archived rows are excluded by FilterArchivablesQueryBuilder at the root of the query instead: soft delete works, nothing is ever suspended — but archived rows are not filtered out of an Include(...)d collection.

IsArchived stays the entity property: what DELETE sets and what a restore clears. Archived is the search knob; the two never mix.

⚠️ DELETE stops deleting the moment an entity implements IArchivable. Same route, same 200, same affected count — the row is only flagged, and nothing in the response distinguishes the two. Decide per entity whether callers expect erasure; there is no hard-delete endpoint on an archivable entity.

⚠️ Restore is the reason IsArchived must stay on TInputDto. Excluding it as “server-owned” (it reads like an auto-generated flag) still leaves the row writable — the write path is archived-inclusive — but no payload can clear the flag, while lists hide the row and GET /{id} 404s.

⚠️ Preserving the persisted flag is DTO-only: on an entity-typed write surface (EntityControllerBase<TEntity>, or auto-endpoints without UseMapping<TDto, TInputDto>()) a PUT that omits isArchived deserializes it to false and un-archives the row — the entity always declares the property, so omitted and explicit false are indistinguishable.

Single-field PATCH / state toggle

Flip one field (e.g. IsActive) without a full update: expose it on TInputDto and let the base PATCH /{id} map only the supplied fields — no custom action needed.

curl -X PATCH {base}/xs/{id} -H "Content-Type: application/json" -d '{ "isActive": false }'
// Server-side (seeding/jobs) — there is no service.Patch; load, flip, Modify, persist:
var item = await service.Details(id);
item!.IsActive = false;
await service.Modify(item);
await service.SaveChanges();      // base controllers SaveChanges for you; direct callers must

Toggling a join/link row? A row managed via e.Related() is owned by the parent, but it can still get its own PATCH route. Two supported ways:

1. Give the child its own .For<>() + controller (costs a budget slot, gets the full endpoint set):

e.For<ProductTag, int, ProductTagSearchObject>(/* … */);
// class ProductTagController : EntityControllerBase<ProductTag, int, ProductTagSearchObject, ProductTagDto, ProductTagInputDto>;
// then: PATCH {base}/product-tags/42 -d '{ "isActive": false }'   (IsActive must be on ProductTagInputDto)

✅ Safe as long as the parent’s TInputDto does not carry the collection. With it null, the parent’s sync short-circuits and never touches these rows, so the toggle is authoritative. Startup validation still warns (it can’t inspect DTO shapes) — expected here. ⚠️ If the parent does send the collection, its next save re-diffs and reverts the toggle. Remove the collection from the parent’s input DTO, or drop one of the two registrations.

2. Hand-write a minimal PATCH controller, no .For<>() — when you only need the toggle, not a whole endpoint set. No second registration means no budget slot and no validator warning; inject the DbContext directly:

[HttpPatch("product-tags/{id:int}/active")]
public async Task<IActionResult> SetActive(int id, [FromBody] bool isActive)
{
    var row = await db.ProductTags.FindAsync(id);
    if (row == null) return NotFound();
    row.IsActive = isActive;
    await db.SaveChangesAsync();   // no base controller here — save explicitly
    return NoContent();
}

The same condition applies: the parent must not send the collection back, or it re-diffs over your write.

⚠️ [FromBody] bool needs a content type the browser client does not send. axios JSON-serializes objects and arrays, but passes a bare true/false through without Content-Type: application/json, so ASP.NET answers 415 — while curl -H "Content-Type: application/json" succeeds, which makes the API look fine and the SPA look broken. Either set the header at the call site (axios.patch(url, isActive, { headers: { "Content-Type": "application/json" } })) or take a one-property body ({ "isActive": true }), which serializes correctly with no special-casing.

Cross-entity aggregates & report endpoints

A dashboard total (spend by month, counts per status, top suppliers) belongs to no single entity, so there is no SearchObject that can express it and no entity service that should own it. Write a plain ControllerBase alongside the entity controllers and query the DbContext directly:

[ApiController, Route("dashboard")]                 // RoutePrefixConvention still prefixes it
public class DashboardController(AppDbContext db) : ControllerBase
{
    [HttpGet("spend-by-month")]
    public async Task<IActionResult> SpendByMonth(int year) =>
        Ok(await db.Interventions.AsNoTracking()
            .Where(x => x.Date.Year == year)
            .GroupBy(x => x.Date.Month)
            .Select(g => new { Month = g.Key, Total = g.Sum(x => x.Cost) })
            .ToListAsync());
}

This is the sanctioned shape, not a deviation — but keep it read-only, and know what you are outside of:

Domain actions on an entity resource

A state machine (submit / approve / reject / reopen) is neither a CRUD write nor a report. It belongs beside the entity controller on the same resource route, as a second controller with distinct templates — this is supported, and keeps the guard off the hot path where every ordinary PATCH would pay for it:

using Regira.Entities.Web.Controllers;                                       // this.Details<TEntity, TDto>(id)
using Regira.Entities.Web.Models;                                            // DetailsResult<T>

public enum RequestStatus { Draft, Submitted, Approved }

public class CreditRequest : IEntity<int>
{
    public int Id { get; set; }
    public RequestStatus Status { get; set; }
    public DateTime? DecidedOn { get; set; }
}

public class CreditRequestDto { public int Id { get; set; } public RequestStatus Status { get; set; } public DateTime? DecidedOn { get; set; } }
public class DecisionInput { public string? Reason { get; set; } }

[ApiController, Route("credit-requests")]                                    // same prefix as the entity controller
public class CreditRequestWorkflowController(IEntityService<CreditRequest, int> service) : ControllerBase
{
    [HttpPost("{id:int}/approve")]
    public async Task<ActionResult<DetailsResult<CreditRequestDto>>> Approve(int id, [FromBody] DecisionInput input)
    {
        var item = await service.Details(id);
        if (item == null) return NotFound();
        if (item.Status != RequestStatus.Submitted)
        {
            ModelState.AddModelError(nameof(item.Status), "Only a submitted request can be approved.");
            return ValidationProblem(ModelState);
        }

        item.Status = RequestStatus.Approved;                                // …decide, stamp
        item.DecidedOn = DateTime.UtcNow;
        await service.Modify(item);
        await service.SaveChanges();                                         // no base controller here — save explicitly
        return await this.Details<CreditRequest, CreditRequestDto>(id) ?? NotFound();   // re-read → fresh navigations, the DTO, { "item": … }
    }
}

Role-gated transitions (server-owned state)

When transitions are privileged — approve/reject for managers while any employee may edit their own request — keeping Status on TInputDto hands every PATCH caller a state pen. Invert the pattern:

  1. Take the workflow fields off TInputDto (Status, DecidedOn, decider ids). The role-gated action is now the only writer.
  2. Restore them in a prepper so ordinary PUT/PATCH round-trips them untouched instead of resetting them — guarded by a scoped flag the trusted writer flips, or the restore would also undo the action’s own write. That flag is why these fields are not [ServerOwned]: its restore has no bypass, so the action’s Modify would be reverted too.
public sealed class WorkflowContext { public bool IsTrustedWriter { get; set; } }    // services.AddScoped

// registered via e.AddPrepper<CreditRequestGuard>(); original is the framework's archived-inclusive re-read
public class CreditRequestGuard(WorkflowContext workflow) : EntityPrepperBase<CreditRequest>
{
    public override Task Prepare(CreditRequest modified, CreditRequest? original, CancellationToken token = default)
    {
        if (original != null && !workflow.IsTrustedWriter)
        {
            modified.Status = original.Status;
            modified.DecidedOn = original.DecidedOn;
        }
        return Task.CompletedTask;
    }
}
  1. Gate the action controller ([Authorize(Roles = "Manager")] or a policy — spellings and policies in Regira.Security → security.instructions → Roles end-to-end) and flip the flag before writing:
workflowContext.IsTrustedWriter = true;
item.Status = RequestStatus.Approved;
item.DecidedOn = DateTime.UtcNow;
await service.Modify(item);
await service.SaveChanges();

A seeder that stamps historical states is a trusted writer too — flip the same flag in its scope. The guard stays in the prepper (not the controller) so every write path — CRUD PUT/PATCH, other services, future endpoints — passes through it.

⚠️ Gate the transition set, not only the fields. The guard decides who writes the fields; which role may reach which state is a second rule. When a generic action (POST {id}/transitions taking the target state) reaches a state that a role-gated one (POST {id}/cancel) also reaches, the attribute on the gated action protects nothing — the caller takes the other route. Put the role-per-target-state check in the one service every transition goes through, and let the endpoint attributes only narrow it.

An append-only history (a status log, an audit trail of transitions) is the same guard on its own entity: the workflow service is its only writer, so its validator refuses any create or update made without the trusted-writer flag, the parent’s input DTO leaves the collection out (one writer per save path), and its controller exposes no DELETE (override it to return 405).

Input validation with FluentValidation

Regira.Entities.Validation.FluentValidation runs AbstractValidator<T> rules as the write pipeline’s validator stage (entities.instructions §Step 8 → Validators): after every prepper, on DELETE too, with every error in one 400. Add the package and call UseFluentValidation inside UseEntities():

using FluentValidation;
using Regira.Entities.DependencyInjection.ServiceCollections;
using Regira.Entities.Validation.FluentValidation;

public enum OrderStatus { Pending, Shipped, Delivered }

public class Customer : IEntityWithSerial
{
    public int Id { get; set; }
    [MaxLength(64)] public string? Name { get; set; }
}

public class Order : IEntityWithSerial
{
    public int Id { get; set; }
    [MaxLength(20)] public string? Code { get; set; }
    public int CustomerId { get; set; }
    public OrderStatus Status { get; set; }
    public ICollection<OrderLine>? Lines { get; set; }
}

public class OrderLine : IEntityWithSerial
{
    public int Id { get; set; }
    public int OrderId { get; set; }
    public int Quantity { get; set; }
}

public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<Customer> Customers => Set<Customer>();
    public DbSet<Order> Orders => Set<Order>();
}

public static class OrderStatusRules
{
    public static bool CanMove(OrderStatus from, OrderStatus to) => to >= from;
}

public class OrderValidator : AbstractValidator<Order>
{
    public OrderValidator(AppDbContext db)
    {
        RuleFor(x => x.Code).NotEmpty().MaximumLength(20);

        // db sees every row: with scoped reads, check through the filtered read service (entities.instructions §Step 8 → Validators)
        RuleFor(x => x.CustomerId)
            .MustAsync((id, ct) => db.Customers.AnyAsync(c => c.Id == id, ct))
            .WithMessage(x => $"Customer {x.CustomerId} does not exist");

        // a Related() collection is validated through its parent — keys like Lines[1].Quantity
        RuleForEach(x => x.Lines).ChildRules(line => line.RuleFor(l => l.Quantity).GreaterThan(0));

        RuleFor(x => x.Status)
            .Must((_, status, ctx) => ctx.GetOriginal() is not { } stored || OrderStatusRules.CanMove(stored.Status, status))
            .WithMessage("Status change not allowed");

        RuleSet(EntityRuleSets.Remove, () =>
            RuleFor(x => x.Status).NotEqual(OrderStatus.Shipped).WithMessage("A shipped order cannot be deleted"));
    }
}

public static class OrderServiceConfiguration
{
    public static EntityServiceCollection<AppDbContext> AddOrders(this IServiceCollection services)
        => services
            .UseEntities<AppDbContext>(o =>
            {
                o.UseDefaults();
                // registers the assembly's validators (scoped, so they can take the DbContext)
                o.UseFluentValidation(typeof(OrderValidator).Assembly);
            })
            .For<Order>(e => e.Related(x => x.Lines))
            .For<Customer>();
}

Role-gated write authorization filter

Everyone signed in may read; only some roles may write. [Authorize(Roles = …)] cannot express it — it gates a controller’s reads too — so the tier goes in one global filter. It fails closed: a write to a controller it has no entry for is refused and logged, so a controller added later, or one named differently than the list expects, shows up as a 403 the first time anyone writes through it, never as an open door.

It is an authorization filter, so it runs before model binding: an unauthorized write gets its 401 or 403 whatever its body holds, where an action filter runs after [ApiController]’s model validation and answers a malformed body with a 400 listing the model’s fields:

using Microsoft.AspNetCore.Authorization;              // IAllowAnonymous
using Microsoft.AspNetCore.Http;                       // HttpMethods
using Microsoft.AspNetCore.Mvc;                        // ChallengeResult, ForbidResult
using Microsoft.AspNetCore.Mvc.Controllers;            // ControllerActionDescriptor
using Microsoft.AspNetCore.Mvc.Filters;                // IAsyncAuthorizationFilter, AuthorizationFilterContext
using Microsoft.Extensions.Logging;
using Regira.Security.Authentication.Jwt.Extensions;   // FindRoles() — package Regira.Security.Authentication

// builder.Services.AddControllers(o => o.Filters.Add<WriteAuthorizationFilter>());
public class WriteAuthorizationFilter(ILogger<WriteAuthorizationFilter> logger) : IAsyncAuthorizationFilter
{
    // Keyed on the controller TYPE: a renamed or mistyped controller fails to compile instead of going ungated.
    // Every controller that takes writes has an entry — an attachment controller is a controller of its own.
    // An empty array lets any signed-in user write: the account controllers' auth/validate and auth/refresh are
    // guarded POSTs every user must reach, whatever the role.
    private static readonly Dictionary<Type, string[]> WriteRoles = new()
    {
        [typeof(ProductController)] = ["Administrator", "Editor"],
        [typeof(ProductAttachmentController)] = ["Administrator", "Editor"],
        [typeof(AccountController)] = [],
        [typeof(PasswordController)] = [],
        [typeof(UsersController)] = ["Administrator"],     // POST users creates an account
    };

    public Task OnAuthorizationAsync(AuthorizationFilterContext context)
    {
        var action = context.ActionDescriptor as ControllerActionDescriptor;
        var route = action?.AttributeRouteInfo?.Template ?? "";

        // Reads: every GET, plus the two POST query overloads. Everything else writes — including the
        // attachment controller's upload/replace and any custom action, which is the safe default.
        var isRead = HttpMethods.IsGet(context.HttpContext.Request.Method)
            || route.EndsWith("/search", StringComparison.OrdinalIgnoreCase)
            || route.EndsWith("/list", StringComparison.OrdinalIgnoreCase);

        // A write is public only when its ACTION says so: sign-in, password recovery, confirm-email, a
        // self-registration override. [AllowAnonymous] on a controller opens its reads, never its writes.
        var isPublicWrite = action?.MethodInfo.GetCustomAttributes(inherit: true).OfType<IAllowAnonymous>().Any() == true;

        if (isRead || isPublicWrite)
        {
            return Task.CompletedTask;
        }

        if (action is null || !WriteRoles.TryGetValue(action.ControllerTypeInfo.AsType(), out var roles))
        {
            // fail closed — and say which controller is missing, or the 403 reads like a role problem
            logger.LogWarning("WriteAuthorizationFilter has no entry for {Controller}; its writes are refused. Add it to WriteRoles.",
                action?.ControllerTypeInfo.Name ?? context.ActionDescriptor.DisplayName);
            context.Result = new ForbidResult();
        }
        else if (context.HttpContext.User.Identity?.IsAuthenticated != true)
        {
            context.Result = new ChallengeResult();      // 401: a controller open for anonymous reads gets here
        }
        // FindRoles() reads all three role-claim spellings. User.IsInRole reads only the principal's
        // RoleClaimType, the quiet 403 when an inbound claim map rewrites `role` (security.instructions →
        // Claim normalization).
        else if (roles.Length > 0 && !roles.Intersect(context.HttpContext.User.FindRoles(), StringComparer.OrdinalIgnoreCase).Any())
        {
            context.Result = new ForbidResult();
        }
        return Task.CompletedTask;
    }
}

⚠️ POST is not a reliable “write” signal here — POST /{entity}/search and POST /{entity}/list are the array-of-search-objects read overloads, so a bare method test 403s the reader’s own list screen. Hence the two route exclusions above.

⚠️ Every writing controller needs an entry — the account controllers included. auth/validate and auth/refresh are guarded POSTs every identity must reach: give AccountController an empty entry, or the filter refuses ordinary users their own session. Actions that carry [AllowAnonymous] themselves (sign-in, password recovery and reset, users/confirm-email) pass without one.

⚠️ A catalogue open for anonymous browsing keeps its writes gated. The obvious way to open its reads is [AllowAnonymous] on the controller, which marks every action, its writes included. The filter therefore honours [AllowAnonymous] only on the action itself: a write to that controller still needs its entry, an anonymous caller gets a 401 and a signed-in one without the role a 403. Never widen the exemption to EndpointMetadata, which carries the controller’s attribute too.

⚠️ Attachment controllers do not share the entity controller’s entry. ProductAttachmentController is its own controller, so it needs its own entry for file upload (POST {objectId}/files), file replace (PUT {objectId}/files/{id}), the link’s update (PUT {objectId}/attachments/{id}) and its delete (DELETE attachments/{id}). Without one those writes are refused and logged, not left open.

Check it with a signed-in user outside the roles: a write to a gated controller answers 403, also with a malformed body, a POST …/search answers 200, and no warning names a controller you meant to list. On a controller open for anonymous reads, the same write without a token answers 401.

Owned children that are both sortable and individually togglable

Let write cardinality decide who owns each field. A field only meaningful relative to its siblings is a collection-level write and rides the parent’s TInputDto; a field that changes one row in isolation is a per-row write and gets its own endpoint.

Field Cardinality Owner
SortOrder affects many rows at once — positions are relative parent’s TInputDto (via SetSortOrder())
IsDone / IsActive affects exactly one row custom PATCH route

⚠️ The sync rewrites whole rows, not just the fields you own at collection level. Once the parent sends the collection, UpdateRelatedCollection marks every matched row Modified and writes all its values from the payload — so the per-row field resets to default on every list save unless a parent-level e.Prepare(entity, dbContext) hook (registered before Related()) re-stamps it from the store first.

→ See: entities.examples.md — Additional Patterns > Sortable owned child with a per-row toggle for the full slice: entity, both DTOs, the guard, the PATCH controller, and the registration-order and covariance caveats.

Renamed DTO property

Mapster maps by name only — a DTO property renamed from its entity counterpart (e.g. OrderDto.Lines for Order.OrderLines) is silently left null in both directions. Wire both directions inline on the typed mapping builder:

e.UseMapping<OrderDto, OrderInputDto>()
    .After((order, dto) => dto.Lines = order.OrderLines?
        .Select(l => new OrderLineDto { Id = l.Id, ProductId = l.ProductId, Quantity = l.Quantity, UnitPrice = l.UnitPrice })
        .ToList())
    .AfterInput((input, order) => order.OrderLines = input.Lines?
        .Select(l => new OrderLine { Id = l.Id, ProductId = l.ProductId, Quantity = l.Quantity })
        .ToList());

Keep both on the typed chain — the class-based .After<TImplementation>() returns the untyped builder, after which .AfterInput(...) no longer compiles (CS1061). Prefer keeping DTO property names aligned with the entity so convention mapping just works.

The typed e.Prepare(async (entity, dbContext) => …) overload (Step 8) hands you the strongly-typed DbContext, so a prepper can mutate related rows, not just the entity’s own fields — decrement stock, bump a denormalized counter. Rows you load are tracked, so the parent’s SaveChanges() persists them in the same transaction:

e.Prepare(async (order, dbContext) =>
{
    foreach (var line in order.Lines ?? [])
    {
        var product = await dbContext.Set<Product>().FindAsync(line.ProductId)
            ?? throw new EntityInputException<Order>($"Product {line.ProductId} not found");
        if (product.StockQuantity < line.Quantity)
            throw new EntityInputException<Order>($"Insufficient stock for {product.Title}");
        product.StockQuantity -= line.Quantity;   // tracked → saved with the order
    }
});

Server-owned / immutable fields on update

⚠️ TInputDto deliberately omits server-owned fields (OwnerId-style FKs, generated codes, computed totals) — so on PUT/PATCH they map onto the entity as null/default and are written back that way. This is about scalars and FKs: a null/default value maps straight onto the column and is written. (Owned collections are governed separately — the Related() sync leaves a null collection untouched and treats [] as delete-all; keep them off the parent’s input DTO or declare the property nullable and uninitialized, per Step 5.) It compiles, returns 200, and corrupts data: a computed Total becomes 0 after a status-only PATCH, a [Required]/NOT NULL column 500s on the first edit, and “generate if empty” logic re-mints values on every save. This applies to PATCH too — the merge patch is applied through TInputDto, so a field the DTO does not declare cannot survive it.

After Step 5, list every field you excluded from TInputDto; each needs restoring on update. Declare it and the framework does the restoring — [ServerOwned] (namespace Regira.Entities.Attributes) to protect, e.ServerOwned(x => x.Code, mint) to protect and mint on create:

public class Order : IEntity<int>
{
    public int Id { get; set; }
    [ServerOwned] public string? Code { get; set; }
    [ServerOwned] public decimal Total { get; set; }
    public string? Status { get; set; }
}

// registration — the attribute alone needs none; this adds the create-time mint
services.UseEntities<AppDbContext>(o => o.UseDefaults())
    .For<Order>(e => e
        .ServerOwned(x => x.Code, _ => $"ORD-{Guid.NewGuid():N}"[..16])
        .Related(x => x.Lines, r => r.ServerOwned(x => x.UnitPrice)));   // owned children too

Cases the declaration does not cover, and what to use instead:

What you need Use
Mint from an injected service (a code generator, IHttpContextAccessor), or re-derive on every save a prepper — EntityPrepperBase<T>.Prepare(modified, original, …), registered with e.AddPrepper<T>()
Stamp the field even when a raw-DbContext writer creates the row (what HasCreatedDbPrimer does for Created) a primer — accepting that it reverts such a writer’s updates too
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 (§Server-generated sequential codes)

The primer form, for that second case — stamp on create, restore on update:

public class ShoppingListOwnerPrimer(IHttpContextAccessor httpContextAccessor) : EntityPrimerBase<ShoppingList>
{
    public override Task PrepareAsync(ShoppingList entity, EntityEntry entry, CancellationToken token = default)
    {
        if (entry.State == EntityState.Modified)                                  // update — restore the stored owner
            entity.OwnerId = (string?)entry.OriginalValues[nameof(entity.OwnerId)];
        else if (entry.State == EntityState.Added && httpContextAccessor.HttpContext is { } http)
            entity.OwnerId = http.User.FindUserId();                               // create — stamp from the claim, never the body; a seeder (no request) keeps its owner
        return Task.CompletedTask;
    }
}
// Registration: e.AddPrimer<ShoppingListOwnerPrimer>();  (+ AddHttpContextAccessor() once)

entry.OriginalValues carries only scalars/FKs (never navigations) and already holds the stored values, so the restore is authoritative and needs no query. The same shape guards any immutable value (created-by, invoice number, source-system id).

Computed from the stored graph? A Total diffed against the original lines needs the whole prior row — use a prepper instead: EntityPrepperBase<T>.Prepare(modified, original, …) hands you the full original entity (null on create); register with e.AddPrepper<T>().

Another service writes the field through the raw DbContext? [ServerOwned]/e.ServerOwned(…) already handles it — it is a prepper, so it never runs on that writer’s save. Only the primer form above needs the warning; see Primer vs prepper when a second writer exists below. A writer going through IEntityService is restored like any client — §Role-gated transitions covers that case.

Optimistic concurrency (stale-write detection)

Two users open the same row and both save. Without a concurrency token the second save silently overwrites the first — last write wins, 200 OK. With one, the write built on the stale read answers 409 Conflict and the first user’s change survives.

It is off by default, and an app stays without it unless the user asks for it or the app is an advanced one: several users editing the same rows, or authorization and approval rules where a stale write must not win. Every entity that implements it needs its token on both DTOs and a client that sends it back, and a 409 the UI handles — cost a simple app does not need. UseDefaults() wires the token convention and its primer either way; they act only on an entity that implements IHasConcurrencyToken or declares a token of its own.

The write path compares every version stamp — a concurrency token the server moves on each write — with the value the client sent — the one it read — never with the row the update reloads. The token is an ordinary DTO field: GET returns it, PUT/PATCH send it back, the SaveResult returns the new one. A stale value matches no row, and the write service rethrows EF’s concurrency failure as EntityConcurrencyException → 409.

1. Declare a token that moves on every write. On any provider, SQLite included, the marker is the whole job:

public class Order : IEntity<int>, IHasConcurrencyToken
{
    public int Id { get; set; }
    public string? Status { get; set; }
    public Guid ConcurrencyToken { get; set; }   // no initializer — see step 2
}

UseEntities<TContext>(o => o.UseDefaults()) declares ConcurrencyToken a concurrency token from the context’s options (DbContextWiring.ConcurrencyTokens — no DbContext change) and registers HasConcurrencyTokenDbPrimer, which mints a new value on every insert and update, a soft delete and a raw-DbContext write included — so a client that read the row before such a write is refused afterwards (what the raw write itself is checked against is in the table below). A DbContext you construct yourself takes .AddConcurrencyTokenConvention() on its options builder; an explicit .IsConcurrencyToken(false) opts one entity type out. Adding the interface to an existing entity adds a column, so create a migration. Startup validation reports a marker whose context never got the wiring (error) and one that nothing mints (warning).

A token you declare yourself goes through the same check:

Token Declaration What moves it
SQL Server rowversion [Timestamp] public byte[]? RowVersion { get; set; } the database, on every UPDATE
PostgreSQL xmin [Timestamp] public uint Version { get; set; } (Npgsql maps it to xmin) the database
Application-owned [ConcurrencyCheck, VersionStamp] public Guid Version { get; set; } a primer you register — HasConcurrencyTokenDbPrimer is the one to copy
Data column [ConcurrencyCheck] public string? LastName { get; set; } nothing — the client edits it

A token is a version stamp when the database generates it on update, when it is the marker’s ConcurrencyToken, or when it carries [VersionStamp] (Regira.Entities.Attributes). Declare an application-owned token so: the check then compares the client’s value on every write, and startup validation checks its DTOs as below. An undeclared token is treated as a stamp only on a write where a prepper or primer changes it — which misses a primer that can produce the value the client already holds (a hash of the content: the stale client that sends back what it read is let through) — and startup validation reports its findings only as Info. A token nothing moves is a data column: the client’s value is the edit itself, so it is written as sent — a change or a clear — and compared with nothing the client read: only a write racing the save is caught. That is also what an application-owned token without its primer gets, so two clients holding the same value both pass. The flip side: a primer that rewrites an undeclared data column in place (trimming, rounding) makes it look like a version stamp, and the edit it rewrote answers 409.

2. Carry it on both DTOs, uninitialized — public Guid ConcurrencyToken { get; set; } on OrderDto and OrderInputDto. Startup validation reports the shapes that break the check:

These apply to declared version stamps; a data column needs none of them, so an undeclared token only gets them as Info.

Deleting by key still works, at one read per row. A delete is commonly issued from a stub — Remove(new Order { Id = id }) — which carries no token, and comparing the stored row against Guid.Empty would match nothing and fail as a conflict forever. An absent token is read as the absence of a claim, so the primer reads the stored token and deletes against that: the delete goes through, while a caller that does supply a token keeps its check and an already-deleted row still reports a conflict. The soft delete of an IArchivable stub is checked the same way; it writes the archive flag and the primers’ stamps, never the stub’s empty values. The cost is one extra SELECT per deleted row that carries the marker, so removing N stubs in a loop is N extra round trips — load the entities you are deleting (a single query) when that matters. On a synchronous SaveChanges() each of those reads blocks the calling thread. One stub still needs the entity loaded: one whose row references its own children, whose reference is dropped by a direct UPDATE before any primer runs.

3. Handle the 409 on the client. The body is a ProblemDetails titled “Concurrency conflict” — the constraint 409 is titled “Conflict” — so the client can tell “reload and try again” from “fix the input”. Reload the row, which brings its current token, and let the user re-apply the edit.

What each write is checked against:

Write Checked against
PUT carrying the token the client’s token — a stale one answers 409
PUT without it (null, empty, Guid.Empty, 0) nothing the client read: it writes, only a write racing it is caught, and the empty value never overwrites the token (the marker’s primer still mints a new one). [VersionStamp(Required = true)] on the token refuses it instead — 400 naming the token (below), before anything is attached — for a client that must always prove what it read; on the marker, put the attribute on the implementing ConcurrencyToken property. An insert is never refused
PATCH the token in the body when it carries one; otherwise the merge base supplies the value read at PATCH time
DELETE, and child rows a save drops no client token reaches them — only a write racing them is caught
Your own code on the raw DbContext (load, copy the DTO, SaveChanges()) the token the entity was loaded with — copying the client’s token onto a tracked entity changes only its current value, so a stale client wins. Set the original yourself: db.Entry(order).Property(x => x.ConcurrencyToken).OriginalValue = dto.ConcurrencyToken
Owned children (Related()) each child’s own token, when it declares one; one stale child fails the whole save. A CLR type EF maps more than once — a shared-type entity, an owned type with several owners — has no single model to read its token from, so only a write racing it is caught
A data-column token the stored row — only a write racing the save is caught

Aggregates over a non-owned child collection

The case above assumes the children ride the parent’s DTO. When they don’t — the optional parent FK row of the Relationship Patterns decision table, Invoice.Total summed from the Interventions that point at it via InvoiceId? — there is no incoming collection to diff, and there must not be: the child owns that write. Read the aggregate from the store instead:

e.Prepare(async (invoice, dbContext) =>
{
    invoice.SubTotal = invoice.Id > 0                          // nothing points at an unsaved parent yet
        ? await dbContext.Interventions.AsNoTracking()
            .Where(i => i.InvoiceId == invoice.Id).SumAsync(i => i.TotalCost)
        : 0m;
    invoice.VatAmount = Math.Round(invoice.SubTotal * invoice.VatRate / 100m, 2);
    invoice.TotalAmount = invoice.SubTotal + invoice.VatAmount;
});

Three consequences follow from the child owning the write:

Server-generated sequential codes

Code = $"ORD-{Guid.NewGuid():N}"[..16] is unique and unordered. A sequential code (REQ-2026-00001) that also survives bulk seeding cannot count rows per item: queued rows are invisible to a query until SaveChanges(), so a COUNT(*)-per-row generator is both N+1 and duplicate-prone — it hands the same number to every row in the wave. Prime the counter once and increment in memory:

public class RequestCodeGenerator(IServiceScopeFactory scopeFactory)
{
    private readonly SemaphoreSlim _lock = new(1, 1);
    private readonly Dictionary<int, int> _last = new();       // year -> last sequence issued

    public async Task<string> Next(int year)
    {
        var prefix = $"REQ-{year}-";
        await _lock.WaitAsync();
        try
        {
            if (!_last.TryGetValue(year, out var seq))
            {
                using var scope = scopeFactory.CreateScope();
                var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
                // The suffix is zero-padded to a fixed width, so the lexical max IS the numeric max —
                // one indexed row, and no client-side scan to prime from.
                var highest = await db.Requests.IgnoreQueryFilters()   // archived rows still hold their code
                    .Where(x => x.Code!.StartsWith(prefix))
                    .OrderByDescending(x => x.Code)
                    .Select(x => x.Code!)
                    .FirstOrDefaultAsync();
                seq = highest is null ? 0 : int.Parse(highest[prefix.Length..]);
            }
            _last[year] = ++seq;
            return $"{prefix}{seq:D5}";
        }
        finally { _lock.Release(); }
    }
}
// services.AddSingleton<RequestCodeGenerator>();  then inject it into the primer that stamps Code on create

⚠️ Prime from the highest code, never from a row count. CountAsync() reads as the same thing and is wrong the moment a row is hard-deleted: with 00001–00005 on file and 00003 deleted, the count is 4 and the next mint is 00005 — a duplicate that dies on the unique index. IgnoreQueryFilters() covers archived rows; nothing covers deleted ones, so the sequence has to be derived from what the codes actually say.

Two constraints come with the shape:

Stamp from a primer exactly as above: mint on Added, restore from entry.OriginalValues on Modified. The primer is also what keeps the sequence gapless: it runs inside SaveChanges(), after the validators, so a create they refuse uses no number. Minted in a prepper or with e.ServerOwned(x => x.Code, mint), which run before the validators, the refused create has already taken its number, and the next one skips it. Only a save that then fails in the database still leaves a gap.

⚠️ Primer vs prepper when a second writer exists. A prepper runs only on the entity-service write path (IEntityService.Add/Modify/Save — so original is null on create, the stored row on update). A primer is an EF SaveChangesInterceptor — it also runs when a domain/workflow service saves through the raw DbContext. So a primer that restores a field from entry.OriginalValues reverts that service’s own legitimate write. If a field is server-owned and transitioned by such a service (a status/state machine), guard it with a prepper; reserve the primer for fields nothing outside the entity pipeline writes (Created, Code).

Public (anonymous) attachment downloads

<img src> / <a href> requests carry no Authorization header, so on a secured API (MapControllers().RequireAuthorization() or [Authorize]) every image renders as a 401. The attachment controller’s download actions are virtual — re-expose both download overloads anonymously; uploads, deletes, and the attachment CRUD stay guarded:

[Authorize]
[Route("articles")] // the owner base path — the base declares no class route (§Attachments step 4)
public class ArticleAttachmentController : EntityAttachmentControllerBase<ArticleAttachment>
{
    // UseAttachmentUris() DTO Uris target THIS overload whenever the attachment has a FileName
    // (the normal case for uploads): GET articles/{objectId}/files/{fileName}
    [AllowAnonymous]
    public override Task<IActionResult> GetFile(int objectId, string fileName, bool inline = true)
        => base.GetFile(objectId, fileName, inline);

    // Fallback DTO Uri (no FileName) and direct id-based links: GET articles/files/{id}
    [AllowAnonymous]
    public override Task<IActionResult> GetFile(int id, bool inline = true) => base.GetFile(id, inline);
}

Overriding only the id overload is the trap: the generated Uri points at the filename route, which stays guarded — the <img> still 401s. (Authorization is evaluated on the routed action only; the filename action’s internal call into the id action is a plain method call.) Reserve this for genuinely public assets (product/article pictures) — the routes are guessable; sensitive documents stay on the authenticated path (download them through the shared axios, which sends the bearer).

An entity that references one of its own children

An owner with an optional foreign key to one of its own child rows — while the child’s foreign key back is required, and therefore cascades — makes the two tables reference each other. Two independent things break, and neither failure names the relationship. Startup validation warns about the shape.

Prefer marking the child over pointing at it. A flag or a rank column on the child identifies the same row with no foreign key and neither failure below; an attachment link entity’s SortOrder (assigned from the incoming array position) often already answers it. The rest of this section is for a reference that has to stay.

Wall 1 — SQL Server refuses the schema

Introducing FOREIGN KEY constraint 'FK_ArticleImages_Articles_ArticleId' on table
'ArticleImages' may cause cycles or multiple cascade paths.  (Msg 1785)

The child’s required foreign key cascades, so a reference mapped SetNull or Cascade is a second cascade path between the same two tables. Map it ClientSetNull: NO ACTION in the database, EF nulls the reference on the tracked owner. SQLite does not enforce this, so an app that develops on SQLite meets it at the migration.

Wall 2 — the owner can no longer be deleted

Unable to save changes because a circular dependency was detected in the data to be saved:
'Article [Deleted] ForeignKeyConstraint { 'CoverImageId' } <-
 ArticleImage [Deleted] ForeignKeyConstraint { 'ArticleId' } <- Article [Deleted]'

IEntityService.Remove + SaveChanges — the DELETE route of EntityControllerBase — answers 500 for every owner whose children are loaded. Two dead ends, so they are not rediscovered:

Dropping the reference needs an UPDATE before the DELETEs, so it cannot happen inside one SaveChanges. Override both save methods and delegate:

using System.Threading;
using Regira.Entities.EFcore.Extensions;

public class Article : IEntityWithSerial
{
    public int Id { get; set; }
    public int? CoverImageId { get; set; }
    public ICollection<ArticleImage>? Images { get; set; }
}
public class ArticleImage : IEntityWithSerial
{
    public int Id { get; set; }
    public int ArticleId { get; set; }
}

public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<Article> Articles { get; set; } = null!;
    public DbSet<ArticleImage> ArticleImages { get; set; } = null!;

    protected override void OnModelCreating(ModelBuilder modelBuilder)
        => modelBuilder.Entity<Article>(entity =>
        {
            entity.HasMany(x => x.Images).WithOne()
                .HasForeignKey(x => x.ArticleId).HasPrincipalKey(x => x.Id);
            entity.HasOne<ArticleImage>().WithMany()
                .HasForeignKey(x => x.CoverImageId)
                .OnDelete(DeleteBehavior.ClientSetNull);   // Wall 1
        });

    // Wall 2 — both overloads: overriding only the async one leaves every synchronous caller broken.
    // acceptAllChangesOnSuccess goes to the extension, not into the delegate.
    public override int SaveChanges(bool acceptAllChangesOnSuccess)
        => this.SaveChangesBreakingDeleteCycles(base.SaveChanges, acceptAllChangesOnSuccess);

    public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken token = default)
        => this.SaveChangesBreakingDeleteCyclesAsync(base.SaveChangesAsync, acceptAllChangesOnSuccess, token);
}

SaveChangesBreakingDeleteCycles (Regira.Entities.EFcore.Extensions) scans the change tracker for pairs of rows deleted together that reference each other, nulls the optional side with a direct UPDATE, tells the tracker the database no longer holds that reference, and then runs the save once — one transaction, through the context’s execution strategy. Nothing is accepted before that save returns: if the database rejects it, the rollback and the tracker agree, every change is still pending, and the retry (EF’s own or the caller’s) drops the reference again and completes the whole unit of work. Direct pairs only; a longer ring (A → B → C → A) is left to EF’s own exception.

Pass base.SaveChanges itself as the delegate and give acceptAllChangesOnSuccess to the extension, which hands it to the one save unchanged. SaveChanges(false) + AcceptAllChanges() therefore behaves exactly as it does without the extension: the deletes stay pending until the caller accepts them.

Already in a transaction? One the caller began, or an ambient TransactionScope, already spans both saves, so the extension joins it rather than opening a second one. That is what keeps the shape working under EnableRetryOnFailure(), the standard SQL Server setting: EF’s recipe for combining a retrying strategy with an explicit transaction puts the save inside CreateExecutionStrategy().Execute(...), and a second transaction in there is a second unit of work the retry cannot replay. A bare BeginTransaction() with no strategy around it is refused by EF’s own SaveChanges — does not support user-initiated transactions — extension or not.

Only the pair matters. A save without one calls the real save exactly once and opens no transaction. An owner deleted without its children loaded has no edge and takes that path — the database cascade still removes the child rows. Deleting only a child is untouched too: one row, no cycle, and EF’s own ClientSetNull fixup nulls the owner’s reference. The change tracker is read only when the model has two entity types referencing each other and the provider is relational: a context without the shape pays a cached lookup per save, and the in-memory provider — which enforces no foreign keys and orders no deletes — is passed straight through.

Audit Trail with Custom Primer

Use a global EntityPrimerBase<TInterface> to stamp CreatedBy/ModifiedBy on every entity that implements a shared auditing interface. The primer runs inside EF Core’s SaveChanges interceptor and resolves the current user via IHttpContextAccessor.

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

Hierarchical Data (Self-referencing)

Add Parent, and Children navigation properties. Filter on ParentId or ChildId in the query builder; use x.ParentId == null to return only root items. This is the single-parent tree shape (one ParentId per node).

Multi-parent? A node under several parents is a many-to-many self-reference, not a tree: use a self-referencing join entity (ParentEntities/ChildEntities collections) instead of ParentId. See the RelatedCategory recipe in entities.examples.md — Category entity, with full wiring in entities.advanced.example.md.

QuerySplittingBehavior warning. Eager-loading two or more collection navigations in one query (e.g. ParentEntities + ChildEntities) trips EF Core’s Cartesian-explosion warning. Harmless for small data; otherwise add .AsSplitQuery() inside Includes(...), or set UseQuerySplittingBehavior(SplitQuery) on the provider.

Delete behaviour on a required self-referencing FK. Deleting an in-use node comes back as 409 under either Restrict or ClientNoAction — they differ in who refuses it. Restrict also has EF fix the relationship up client-side, and a hierarchy eager-loads its own children, so the dependents are tracked and the change tracker rejects the delete before the provider is reached. ClientNoAction skips that and lets the database decide, which is the simpler contract for a tree:

builder.HasOne(x => x.Parent).WithMany(x => x.Children)
    .HasForeignKey(x => x.ParentId).OnDelete(DeleteBehavior.ClientNoAction);

A nullable ParentId is the other valid answer: orphaned children survive as new roots.

Beyond direct relations — “everything under X, any depth” filters (AncestorId/OffspringId on the SearchObject), tree endpoints, and in-memory tree assembly with Regira.TreeList: see entities.blueprints.md — Recursive entities (mapped recursive-CTE table-valued functions composed inside query filters).

→ See: entities.examples.md — Category entity

Auto truncate

Use AddAutoTruncateInterceptors() when registering DbContext to prevent string truncation exceptions

DbContext Interceptors — Quick Reference

UseEntities<TContext>(e => e.UseDefaults()) wires all of these (plus the UTC date convention) into the context’s options automatically — AddDbContext only needs the provider. Without UseDefaults(), take the full set with e.AddDefaultInterceptors() or select pieces à la carte with e.WireDbContext(DbContextWiring …) flags.

DbContextWiring flag What it wires
PrimerInterceptors Runs Primers during EF Core SaveChanges (timestamps, soft-delete, audit, custom primers)
NormalizerInterceptors Runs entity normalizers during SaveChanges to populate NormalizedContent and other [Normalized] fields
AutoTruncateInterceptors Silently truncates string values to their [MaxLength] before SaveChanges to prevent DB exceptions
UtcDateTimeConvention Rounds all DateTime properties through the database as UTC
ArchivedQueryFilter Applies the soft-delete filter (e => !e.IsArchived) to every IArchivable entity type — see §Soft Delete
ConcurrencyTokens Declares IHasConcurrencyToken.ConcurrencyToken a concurrency token on every implementing entity type — see §Optimistic concurrency
Reactors Runs registered reactors once a save’s changes are committed (entities.instructions §Step 9 → Reactors)

À-la-carte pattern (no UseDefaults()):

builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlite(connectionString));
builder.Services.UseEntities<AppDbContext>(e =>
{
    e.AddDefaultPrimers();
    e.WireDbContext(DbContextWiring.PrimerInterceptors | DbContextWiring.UtcDateTimeConvention);
});

AddAutoTruncateInterceptors() and AddUtcDateTimeConvention() also exist as plain DbContextOptionsBuilder extensions (Regira.DAL.EFcore) for EF usage without the entities stack, as do AddArchivedQueryFilter() and AddConcurrencyTokenConvention() (Regira.Entities.EFcore.Extensions) — the ones to reach for on a DbContext you construct yourself, which no service-collection wiring can reach.