Regira-Packages

Regira Entities Framework - API Signatures Reference

Exact signatures for interfaces, classes, and extension methods in the Regira Entities framework. Do not guess — look up here first.


Table of Contents

  1. Entity Interfaces
  2. Service Interfaces
  3. Controller Base Classes
  4. Search and Filter Objects
  5. Extension Methods
  6. Service Builders
  7. Mapping and Processing
  8. Response Types
  9. Attachments
  10. Exceptions
  11. Supporting Types

Entity Interfaces

Core Entity Interfaces

using Regira.Entities.Models.Abstractions;

public interface IEntity;
public interface IEntity<TKey> : IEntity
{
    TKey Id { get; set; }
}

// Shortcut for int primary key
public interface IEntityWithSerial : IEntity<int>;

Property Interfaces

using Regira.Entities.Models.Abstractions;

public interface IHasCode
{
    string? Code { get; set; }
}
// Title is read-only on the interface; entity classes may expose a setter
public interface IHasTitle
{
    string? Title { get; }
}
public interface IHasNormalizedTitle : IHasTitle
{
    string? NormalizedTitle { get; set; }
}
public interface IHasDescription
{
    string? Description { get; set; }
}
public interface IHasNormalizedContent
{
    string? NormalizedContent { get; set; }
}
public interface IHasLastNormalized : IHasNormalizedContent, IHasLastModified
{
    DateTime? LastNormalized { get; set; }
}
public interface ISortable
{
    int SortOrder { get; set; }
}

Timestamp Interfaces

Timestamps are UTC: the default primers write DateTime.UtcNow and normalize client-supplied values.

using Regira.Entities.Models.Abstractions;

public interface IHasCreated
{
    DateTime Created { get; set; } // UTC
}
public interface IHasLastModified
{
    DateTime? LastModified { get; set; } // UTC
}

// Combined — most common choice
public interface IHasTimestamps : IHasCreated, IHasLastModified;

// Optimistic concurrency: UseDefaults() declares ConcurrencyToken a concurrency token and mints a new one on every
// write (HasConcurrencyTokenDbPrimer). Guid.Empty from a client means "not sent" — that write is not checked.
public interface IHasConcurrencyToken
{
    Guid ConcurrencyToken { get; set; }
}

Lifecycle Interface

using Regira.Entities.Models.Abstractions;

public interface IArchivable
{
    bool IsArchived { get; set; }
}

Period / Slug / Uri Interfaces

using Regira.Entities.Models.Abstractions;

// ⚠️ members are nullable — implementing them with non-nullable DateTime fails with CS0738.
// UTC instants by default; prefer a DateOnly property of your own for pure calendar semantics.
public interface IHasStartDate { DateTime? StartDate { get; set; } }
public interface IHasEndDate   { DateTime? EndDate { get; set; } }
public interface IHasStartEndDate : IHasStartDate, IHasEndDate;

public interface IHasSlug { string? Slug { get; set; } }
public interface IHasUri  { string? Uri { get; set; } }

Attachment Interfaces

using Regira.Entities.Attachments.Abstractions;

public interface IHasAttachments
{
    ICollection<IEntityAttachment>? Attachments { get; set; }
    bool? HasAttachment { get; set; } // ⚠️ yours to set on the read path (a processor or a mapped projection) — nothing populates it, so it serializes null even for a row that has attachments. `FilterHasAttachment(so.HasAttachment)` does not read it either: it filters on Attachments.Any(). To show a "has documents" flag without loading the collection, set it; otherwise drop it from the DTO.
}

// Typed (int keys) — most common
public interface IHasAttachments<TEntityAttachment>
    : IHasAttachments<TEntityAttachment, int, int, int, Attachment>
    where TEntityAttachment : IEntityAttachment<int, int, int, Attachment>;

public interface IHasAttachments<TEntityAttachment, TKey, TObjectKey, TAttachmentKey, TAttachment>
    where TEntityAttachment : IEntityAttachment<TKey, TObjectKey, TAttachmentKey, TAttachment>
    where TAttachment : class, IAttachment<TAttachmentKey>, new()
{
    ICollection<TEntityAttachment>? Attachments { get; set; }
    bool? HasAttachment { get; set; } // ⚠️ yours to set on the read path (a processor or a mapped projection) — nothing populates it, so it serializes null even for a row that has attachments. `FilterHasAttachment(so.HasAttachment)` does not read it either: it filters on Attachments.Any(). To show a "has documents" flag without loading the collection, set it; otherwise drop it from the DTO.
}

public interface IHasObjectId<TKey>
{
    TKey ObjectId { get; set; }
}

Service Interfaces

Read

using Regira.Entities.Services.Abstractions;

public interface IEntityReadService<TEntity, in TKey>
{
    Task<TEntity?> Details(TKey id, CancellationToken token = default);
    // Explicit archived scope; null falls back to EntityQueryOptions.DefaultArchivedFilter.
    // Only the built-in IArchivable filter reads it — tenant/owner row security still applies.
    // ⚠️ Default interface member (falls back to Details(id, token)) — a custom read service must
    // OVERRIDE BOTH overloads, or the archived-inclusive write path cannot see archived rows and
    // restore silently 404s while everything still compiles.
    Task<TEntity?> Details(TKey id, ArchivedFilter? archived, CancellationToken token = default);
    // ⚠️ first parameter is object? — a positionally-passed CancellationToken binds as the
    // search object and silently filters nothing; call List(null, null, token)
    Task<IList<TEntity>> List(object? so = null, PagingInfo? pagingInfo = null, CancellationToken token = default);
    Task<long> Count(object? so, CancellationToken token = default);
}

public interface IEntityReadService<TEntity, in TKey, in TSearchObject>
    : IEntityReadService<TEntity, TKey>
    where TSearchObject : class, ISearchObject<TKey>, new()
{
    Task<IList<TEntity>> List(TSearchObject? so = null, PagingInfo? pagingInfo = null, CancellationToken token = default);
    Task<long> Count(TSearchObject? so = null, CancellationToken token = default);
}

public interface IEntityReadService<TEntity, in TKey, TSearchObject, TSortBy, TIncludes>
    : IEntityReadService<TEntity, TKey, TSearchObject>
    where TEntity : class, IEntity<TKey>
    where TSearchObject : class, ISearchObject<TKey>, new()
    where TSortBy : struct, Enum
    where TIncludes : struct, Enum
{
    Task<IList<TEntity>> List(
        IList<TSearchObject?> so,
        IList<TSortBy> sortBy,
        TIncludes? includes = null,
        PagingInfo? pagingInfo = null,
        CancellationToken token = default);
    Task<long> Count(IList<TSearchObject?> so, CancellationToken token = default);
}

Write

using Regira.Entities.Services.Abstractions;

public interface IEntityWriteService<TEntity, TKey>
    where TEntity : class, IEntity<TKey>
{
    Task Add(TEntity item, CancellationToken token = default);
    Task<TEntity?> Modify(TEntity item, CancellationToken token = default);
    Task Save(TEntity item, CancellationToken token = default);  // Upsert: Add or Modify
    Task Remove(TEntity item, CancellationToken token = default);
    Task<int> SaveChanges(CancellationToken token = default);
}

The default implementation, and the base of a custom write service:

using Regira.Entities.EFcore.Services;

public class EntityWriteService<TContext, TEntity, TKey>(
    TContext dbContext,
    IEntityReadService<TEntity, TKey> readService,
    IEnumerable<IEntityPrepper> preppers,
    IEnumerable<IEntityValidator> validators,
    ILoggerFactory? loggerFactory = null) : IEntityWriteService<TEntity, TKey>
    where TContext : DbContext
    where TEntity : class, IEntity<TKey>
{
    // runs no validators — a subclass passes IEnumerable<IEntityValidator> to the constructor above
    public EntityWriteService(TContext dbContext, IEntityReadService<TEntity, TKey> readService,
        IEnumerable<IEntityPrepper> preppers, ILoggerFactory? loggerFactory = null);

    // Add / Modify: PrepareItem, then ValidateItem, then the entity is tracked
    public virtual Task PrepareItem(TEntity item, TEntity? original, CancellationToken token = default);
    // not virtual: validators are the one way to refuse a write; their queries on this context track nothing
    public Task ValidateItem(TEntity item, TEntity? original, EntityWriteOperation operation,
        CancellationToken token = default);
    // Remove: ValidateItem with the stored row, then RemoveItem — mark extra rows for a delete here, once the validators passed
    protected virtual Task RemoveItem(TEntity item, CancellationToken token = default);
}
// int-keyed: EntityWriteService<TContext, TEntity>, with the same two constructors

Combined (IEntityService)

using Regira.Entities.Services.Abstractions;

// Full-featured (TKey explicit)
public interface IEntityService<TEntity, TKey, TSearchObject, TSortBy, TIncludes>
    : IEntityReadService<TEntity, TKey, TSearchObject, TSortBy, TIncludes>,
      IEntityService<TEntity, TKey, TSearchObject>
    where TEntity : class, IEntity<TKey>
    where TSearchObject : class, ISearchObject<TKey>, new()
    where TSortBy : struct, Enum
    where TIncludes : struct, Enum;

// Shortcut — int key assumed (most common for injection)
public interface IEntityService<TEntity, TSearchObject, TSortBy, TIncludes>
    : IEntityService<TEntity, int, TSearchObject, TSortBy, TIncludes>,
      IEntityService<TEntity>
    where TEntity : class, IEntity<int>
    where TSearchObject : class, ISearchObject<int>, new()
    where TSortBy : struct, Enum
    where TIncludes : struct, Enum;

Inject as IEntityService<TEntity, TSearchObject, TSortBy, TIncludes> (int key shortcut) when registered with .For<TEntity, TSearchObject, TSortBy, TIncludes>().

IEntityRepository / IEntityManager

Custom services with HasRepository<>() or HasManager<>() per entity, or app-wide with UseEntities(o => o.UseRepository(...)) (see entities.instructions.md → Replacing the repository app-wide).

using Regira.Entities.Services.Abstractions;

// Primary shortcut forms (int key, full-featured)
public interface IEntityRepository<TEntity, TSearchObject, TSortBy, TIncludes>
    : IEntityService<TEntity, TSearchObject, TSortBy, TIncludes>, IEntityRepository<TEntity>;

public interface IEntityManager<TEntity, TSearchObject, TSortBy, TIncludes>
    : IEntityService<TEntity, TSearchObject, TSortBy, TIncludes>, IEntityManager<TEntity>;

// Additional TKey and partial variants follow the same pattern as IEntityService.

EntityWrappingServiceBase

Inject the inner service via constructor; override only the methods you need.

using Regira.Entities.Services.Abstractions;

// Without sort/includes — exposes Service field
public abstract class EntityWrappingServiceBase<TEntity, TKey, TSearchObject>(
    IEntityService<TEntity, TKey, TSearchObject> service) : IEntityService<TEntity, TKey, TSearchObject>
    where TEntity : class, IEntity<TKey>
    where TSearchObject : class, ISearchObject<TKey>, new()
{
    protected readonly IEntityService<TEntity, TKey, TSearchObject> Service = service;

    // All members are virtual:
    public virtual Task<TEntity?> Details(TKey id, CancellationToken token = default);
    public virtual Task<TEntity?> Details(TKey id, ArchivedFilter? archived, CancellationToken token = default); // an archived-explicit read goes straight to the inner service: override both
    public virtual Task<IList<TEntity>> List(TSearchObject? so = null, PagingInfo? pagingInfo = null, CancellationToken token = default);
    public virtual Task<IList<TEntity>> List(object? so, PagingInfo? pagingInfo, CancellationToken token = default);
    public virtual Task<long> Count(TSearchObject? so = null, CancellationToken token = default);
    public virtual Task<long> Count(object? so, CancellationToken token = default);
    public virtual Task Add(TEntity item, CancellationToken token = default);
    public virtual Task<TEntity?> Modify(TEntity item, CancellationToken token = default);
    public virtual Task Save(TEntity item, CancellationToken token = default);
    public virtual Task Remove(TEntity item, CancellationToken token = default);
    public virtual Task<int> SaveChanges(CancellationToken token = default);
}

// Full-featured (int key) — most common; empty body, delegates to explicit-TKey variant
public abstract class EntityWrappingServiceBase<TEntity, TSearchObject, TSortBy, TIncludes>(
    IEntityService<TEntity, int, TSearchObject, TSortBy, TIncludes> service)
    : EntityWrappingServiceBase<TEntity, int, TSearchObject, TSortBy, TIncludes>(service)
    where TEntity : class, IEntity<int>
    where TSearchObject : class, ISearchObject<int>, new()
    where TSortBy : struct, Enum
    where TIncludes : struct, Enum;

// Full-featured (explicit TKey)
public abstract class EntityWrappingServiceBase<TEntity, TKey, TSearchObject, TSortBy, TIncludes>(
    IEntityService<TEntity, TKey, TSearchObject, TSortBy, TIncludes> service)
    : IEntityService<TEntity, TKey, TSearchObject, TSortBy, TIncludes>
    where TEntity : class, IEntity<TKey>
    where TSearchObject : class, ISearchObject<TKey>, new()
    where TSortBy : struct, Enum
    where TIncludes : struct, Enum
{
    // Virtual — override as needed:
    public virtual Task<TEntity?> Details(TKey id, CancellationToken token = default);
    public virtual Task<TEntity?> Details(TKey id, ArchivedFilter? archived, CancellationToken token = default); // an archived-explicit read goes straight to the inner service: override both
    public virtual Task<IList<TEntity>> List(object? so = null, PagingInfo? pagingInfo = null, CancellationToken token = default);
    public virtual Task<IList<TEntity>> List(IList<TSearchObject?> so, IList<TSortBy> sortBy, TIncludes? includes = null, PagingInfo? pagingInfo = null, CancellationToken token = default);
    public virtual Task<long> Count(object? so, CancellationToken token = default);
    public virtual Task<long> Count(IList<TSearchObject?> so, CancellationToken token = default);
    public virtual Task Add(TEntity item, CancellationToken token = default);
    public virtual Task<TEntity?> Modify(TEntity item, CancellationToken token = default);
    public virtual Task Save(TEntity item, CancellationToken token = default);
    public virtual Task Remove(TEntity item, CancellationToken token = default);
    public virtual Task<int> SaveChanges(CancellationToken token = default);
    public virtual TSearchObject? Convert(object? so);

    // Not virtual — they call the inner service directly, and overriding them fails with CS0506:
    public Task<IList<TEntity>> List(TSearchObject? so = null, PagingInfo? pagingInfo = null, CancellationToken token = default);
    public Task<long> Count(TSearchObject? so = null, CancellationToken token = default);
}

Register the wrapper with e.UseEntityService<MyService>() (⚠️ Beware for circular dependency!). You can register implementations for custom interface derived from IEntityService with e.AddTransient<IMyService, MyService>().


Controller Base Classes

The generic type arguments on the controller must exactly match those used in .For<>(). The controller adds TDto and TInputDto on top.

.For<>() registration Required controller base
.For<TEntity>() EntityControllerBase<TEntity, TDto, TInputDto>
.For<TEntity, TKey>() EntityControllerBase<TEntity, TKey, SearchObject<TKey>, TDto, TInputDto>
.For<TEntity, TKey, TSearchObject>() EntityControllerBase<TEntity, TKey, TSearchObject, TDto, TInputDto>
.For<TEntity, TSearchObject, TSortBy, TIncludes>() EntityControllerBase<TEntity, TSearchObject, TSortBy, TIncludes, TDto, TInputDto>
.For<TEntity, TKey, TSearchObject, TSortBy, TIncludes>() EntityControllerBase<TEntity, TKey, TSearchObject, TSortBy, TIncludes, TDto, TInputDto>
using Regira.Entities.Web.Controllers.Abstractions;

// Minimal — no sorting or includes
[ApiController]
public abstract class EntityControllerBase<TEntity, TKey, TSearchObject, TDto, TInputDto>
    : ControllerBase
    where TEntity : class, IEntity<TKey>
    where TSearchObject : class, ISearchObject<TKey>
    where TDto : class
    where TInputDto : class;

// Full-featured — with sorting and includes (int key shortcut)
[ApiController]
public abstract class EntityControllerBase<TEntity, TSo, TSortBy, TIncludes, TDto, TInputDto>
    : EntityControllerBase<TEntity, int, TSo, TSortBy, TIncludes, TDto, TInputDto>
    where TEntity : class, IEntity<int>
    where TSo : class, ISearchObject<int>, new()
    where TSortBy : struct, Enum
    where TIncludes : struct, Enum
    where TDto : class
    where TInputDto : class;

// Full-featured — with explicit TKey
[ApiController]
public abstract class EntityControllerBase<TEntity, TKey, TSo, TSortBy, TIncludes, TDto, TInputDto>
    : ControllerBase
    where TEntity : class, IEntity<TKey>
    where TSo : class, ISearchObject<TKey>, new()
    where TSortBy : struct, Enum
    where TIncludes : struct, Enum
    where TDto : class
    where TInputDto : class;

The generated actions are virtual — override one to add an [Authorize] policy (administrators manage reference data) without re-implementing it. Signatures are identical on both bases; the complex one adds the [FromBody] TSo[] overloads of List/Search:

public virtual Task<ActionResult<DetailsResult<TDto>>> Details([FromRoute] TKey id, [FromQuery] ArchivedFilter? archived = null);
public virtual Task<ActionResult<ListResult<TDto>>>    List([FromQuery] TSearchObject so, [FromQuery] PagingInfo pagingInfo);
public virtual Task<ActionResult<SearchResult<TDto>>>  Search([FromQuery] TSearchObject so, [FromQuery] PagingInfo pagingInfo);
public virtual Task<ActionResult<SaveResult<TDto>>>    Save([FromBody] TInputDto model);
public virtual Task<ActionResult<SaveResult<TDto>>>    Create([FromBody] TInputDto model);
public virtual Task<ActionResult<SaveResult<TDto>>>    Modify([FromRoute] TKey id, [FromBody] TInputDto model);
public virtual Task<ActionResult<SaveResult<TDto>>>    Patch([FromRoute] TKey id);          // reads the body itself
public virtual Task<ActionResult<DeleteResult<TDto>>?> Delete([FromRoute] TKey id);

The verb attribute is inherited by the override, so the route survives without it: declare only the [Authorize] and delegate — return base.Create(model);.

Endpoints exposed by controller bases:

Method Route Action Availability
GET /{id} Details All
GET / List All
POST /list List (body) Complex only
GET /search Search (with count) All
POST /search Search (body) Complex only
POST /save Save (upsert) All
POST / Create All
PUT /{id} Modify (full update) All
PATCH /{id} Patch (partial update, JSON Merge Patch) All
DELETE /{id} Delete All

Complex only = bases with TSortBy + TIncludes. Simple bases omit POST /list and POST /search (the body/array variants), but do expose GET /search (single search object, with count for paging) alongside basic list via GET /?q=…. For response envelope shapes (item / items,count) see entities.instructions.md §Step 13.

A write a validator refuses — save, create, modify, patch or delete — answers 400 with a ValidationProblemDetails.


Search and Filter Objects

using Regira.Entities.Models.Abstractions;
using Regira.Entities.Models;

public interface ISearchObject
{
    string? Q { get; set; }
    DateTime? MinCreated { get; set; }
    DateTime? MaxCreated { get; set; }
    DateTime? MinLastModified { get; set; }
    DateTime? MaxLastModified { get; set; }
    // Bound from ?archived=. null → EntityQueryOptions.DefaultArchivedFilter (Excluded).
    ArchivedFilter? Archived { get; set; }
}

public interface ISearchObject<TKey> : ISearchObject
{
    TKey? Id { get; set; }
    ICollection<TKey>? Ids { get; set; }
    ICollection<TKey>? Exclude { get; set; }
}

// Default implementation (int key) — extend this for custom filters
public record SearchObject : 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; }
}

Extension Methods

EntityExtensions

using Regira.Entities.Extensions;

public static class EntityExtensions
{
    public static bool IsNew<TKey>(this IEntity<TKey> item);
    public static void AdjustIdForEfCore(this IEnumerable<IEntity<int>> items);
    public static void SetSortOrder(this IEnumerable<ISortable> items);
}

ModelBuilderExtensions

using Regira.Entities.EFcore.Extensions;

public static class ModelBuilderExtensions
{
    public const string ArchivedQueryFilterName = "Regira:Archived";

    // Applies e => !e.IsArchived as a NAMED filter to every IArchivable root entity type (a hierarchy is
    // filtered through its root, an owned type through its owner) — the only thing that hides archived rows,
    // Include(...) included. Excluded on the search object composes nothing.
    // OPTIONAL: UseEntities<TContext>(e => e.UseDefaults()) installs the same filter through the context's
    // options (DbContextWiring.ArchivedQueryFilter). Call this only when the context is built outside that
    // wiring, or in place of it. Then it goes AFTER your own HasQueryFilter(...) calls, exactly once;
    // calling it alongside the wiring is safe. Startup validation raises an ERROR when a model ends up with
    // no archived filter. (net8.0: no-op — see entities.patterns → Soft Delete.)
    public static void SetArchivedQueryFilter(this ModelBuilder modelBuilder);
}
public static class DbContextOptionsBuilderExtensions
{
    // The AddDbContext counterpart of SetArchivedQueryFilter: same named filter, applied at model
    // finalization (so after everything OnModelCreating configured). Auto-added by UseDefaults() —
    // call it yourself for a DbContext constructed outside DI (tests, design-time factory, seeding tool),
    // which no service-collection wiring can reach. (net8.0: no filter is installed.)
    public static DbContextOptionsBuilder AddArchivedQueryFilter(this DbContextOptionsBuilder optionsBuilder);
    public static DbContextOptionsBuilder<TContext> AddArchivedQueryFilter<TContext>(
        this DbContextOptionsBuilder<TContext> optionsBuilder) where TContext : DbContext;

    // Declares IHasConcurrencyToken.ConcurrencyToken a concurrency token on every implementing entity type, at
    // model finalization (an explicit .IsConcurrencyToken(false) still wins). Auto-added by UseDefaults() — call
    // it yourself for a DbContext constructed outside DI.
    public static DbContextOptionsBuilder AddConcurrencyTokenConvention(this DbContextOptionsBuilder optionsBuilder);
    public static DbContextOptionsBuilder<TContext> AddConcurrencyTokenConvention<TContext>(
        this DbContextOptionsBuilder<TContext> optionsBuilder) where TContext : DbContext;
}

DeleteCycleExtensions

using Regira.Entities.EFcore.Extensions;

public static class DeleteCycleExtensions
{
    // Two rows deleted together that reference each other — an entity carrying a foreign key to one of its
    // own children — are a save EF Core refuses with "a circular dependency was detected in the data to be
    // saved". Dropping the reference needs an UPDATE before the DELETEs, so it cannot happen inside one
    // SaveChanges: call these FROM the DbContext's own overrides, BOTH of them, passing base.SaveChanges as
    // the delegate. A save with no such pair calls the delegate exactly once and opens no transaction; the
    // change tracker is read only when the model has two entity types referencing each other and the
    // provider is relational (the in-memory provider orders no deletes and is passed straight through).
    // Give acceptAllChangesOnSuccess to the EXTENSION and let the delegate take it as a parameter: the
    // reference is dropped with a direct UPDATE and the delegate then runs exactly once with that flag, so
    // nothing is accepted before the save returns and a failed save leaves every change pending for the
    // retry. A transaction the caller owns (or a TransactionScope) is joined rather than nested, so the
    // pattern holds under EnableRetryOnFailure().
    public static int SaveChangesBreakingDeleteCycles(this DbContext dbContext, Func<bool, int> save,
        bool acceptAllChangesOnSuccess = true);
    public static Task<int> SaveChangesBreakingDeleteCyclesAsync(this DbContext dbContext,
        Func<bool, CancellationToken, Task<int>> save, bool acceptAllChangesOnSuccess = true,
        CancellationToken token = default);
}

Direct pairs only; a longer ring (A → B → C → A) is left to EF’s own exception. Startup validation warns about the shape at registration time. Full recipe: entities.patterns → An entity that references one of its own children.

QueryExtensions

Every method

using Regira.Entities.EFcore.Extensions;

public static class QueryExtensions
{
    // Requires IEntity<TKey>
    public static IQueryable<TEntity> FilterId<TEntity, TKey>(this IQueryable<TEntity> query, TKey? id);
    public static IQueryable<TEntity> FilterIds<TEntity, TKey>(this IQueryable<TEntity> query, ICollection<TKey>? ids);
    public static IQueryable<TEntity> FilterExclude<TEntity, TKey>(this IQueryable<TEntity> query, ICollection<TKey>? ids);

    // Requires IHasStartEndDate — rows whose period contains `date`; a null bound is treated as open.
    // The only built-in helper for the date interfaces, and you call it yourself (no global filter).
    public static IQueryable<TEntity> FilterIsActiveOn<TEntity>(this IQueryable<TEntity> query, DateTime? date);

    // Requires IHasCode
    public static IQueryable<TEntity> FilterCode<TEntity>(this IQueryable<TEntity> query, string? code);

    // Requires IHasTitle
    public static IQueryable<TEntity> FilterTitle<TEntity>(this IQueryable<TEntity> query, ParsedKeywordCollection? keywords);

    // Requires IHasNormalizedTitle
    public static IQueryable<TEntity> FilterNormalizedTitle<TEntity>(this IQueryable<TEntity> query, ParsedKeywordCollection? keywords);

    // Requires IHasNormalizedContent
    public static IQueryable<TEntity> FilterQ<TEntity>(this IQueryable<TEntity> query, ParsedKeywordCollection? keywords);

    // No constraint — keyword search over explicit fields (each keyword must match at least one).
    // Matches the RAW family (TrimmedQW): the fields you name are ordinary columns holding the client's
    // value verbatim. Do not pass a normalized column here — build that predicate with QW yourself.
    public static IQueryable<TEntity> FilterQ<TEntity>(this IQueryable<TEntity> query, ParsedKeywordCollection? keywords,
        Expression<Func<TEntity, string?>> field, params Expression<Func<TEntity, string?>>[] moreFields);

    // Requires IHasCreated — input dates are normalized to UTC (local kinds converted, unspecified assumed UTC)
    public static IQueryable<TEntity> FilterCreated<TEntity>(this IQueryable<TEntity> query, DateTime? minDate, DateTime? maxDate);

    // Requires IHasLastModified — input dates are normalized to UTC
    public static IQueryable<TEntity> FilterLastModified<TEntity>(this IQueryable<TEntity> query, DateTime? minDate, DateTime? maxDate);

    // Requires IHasTimestamps — input dates are normalized to UTC
    public static IQueryable<TEntity> FilterTimestamps<TEntity>(this IQueryable<TEntity> query,
        DateTime? minCreated, DateTime? maxCreated, DateTime? minModified, DateTime? maxModified);

    // Requires class + IArchivable. net10.0: Excluded composes nothing (the named archived EF query filter
    // already hides archived rows); Included/Only suspend that ONE filter by name, never yours. net8.0: no
    // query filter exists, so Excluded composes the predicate itself and nothing is ever suspended.
    public static IQueryable<TEntity> FilterArchivable<TEntity>(this IQueryable<TEntity> query, ArchivedFilter archived);

    // Requires IHasAttachments
    public static IQueryable<TEntity> FilterHasAttachment<TEntity>(this IQueryable<TEntity> query, bool? hasAttachment);

    public static IQueryable<TEntity> SortQuery<TEntity, TKey>(this IQueryable<TEntity> query)
        where TEntity : IEntity<TKey>;

    // Start the ordering, or continue it with ThenBy when one is already applied. ALWAYS use these in
    // SortBy builders — a hand-rolled `is IOrderedQueryable<T>` check throws at request time on EF Core.
    public static IOrderedQueryable<TEntity> OrderOrThenBy<TEntity, TKey>(this IQueryable<TEntity> query, Expression<Func<TEntity, TKey>> keySelector);
    public static IOrderedQueryable<TEntity> OrderOrThenByDescending<TEntity, TKey>(this IQueryable<TEntity> query, Expression<Func<TEntity, TKey>> keySelector);
}

How it decides: it reads query.Expression.Type — whether the composed expression tree already reports an ordering — not the runtime type. That distinction is the whole point: an EF Core query object satisfies is IOrderedQueryable<T> before any ordering exists, which is why the hand-rolled check throws.

Chaining is therefore supported, and is how you write a multi-key sort. Inside one SortBy arm, query.OrderOrThenBy(x => x.LastName).OrderOrThenBy(x => x.FirstName) composes OrderBy(LastName) .ThenBy(FirstName): the first call returns an IOrderedQueryable<T>, so the second sees an ordered expression tree and continues it. No need to split a composite sort across several enum members.


Service Builders

Quick reference — For<>() overload → builder → available methods

Each For<>() overload yields a different builder type, which determines the SortBy lambda arity and whether typed Includes is available. Match the controller base and any manual IEntityService<> resolution to the same generics (see entities.instructions.md §Step 13).

For<>() overload Builder type Tier SortBy lambda Typed Includes Process / Related
For<TEntity>() EntityIntServiceBuilder Simple query => … (1-arg) — Process ✓ · Related<TRelated> ✓
For<TEntity, TKey>() EntityServiceBuilder Simple query => … (1-arg) — Process ✓ · Related<TRelated, TRelatedKey> (2-arg for non-int key)
For<TEntity, TKey, TSearchObject>() EntitySearchObjectServiceBuilder Simple query => … (1-arg) — Process ✓ · Related ✓
For<TEntity, TSearchObject, TSortBy, TIncludes>() ComplexEntityIntServiceBuilder Complex (query, sortBy) => … (2-arg) ✓ Process ✓ · Related<TRelated> ✓
For<TEntity, TKey, TSearchObject, TSortBy, TIncludes>() ComplexEntityServiceBuilder Complex (query, sortBy) => … (2-arg) ✓ Process ✓ · Related ✓

Using the 2-arg SortBy((query, sortBy) => …) on a simple builder is CS1593 — simple builders have no TSortBy. The typed e.Includes((query, includes) => …) overload (keyed to a [Flags] TIncludes) exists only on the two complex builders — but every builder, simple ones included, inherits the untyped e.Includes((query, EntityIncludes?) => query.Include(...)) overload (the “Typed Includes” column below tracks only the typed form), so simple registrations can still eager-load navigations. The single-arg e.Related<TRelated>(…) shortcut works on every int-key builder (incl. the simple For<TEntity, int, TSearchObject>()), and on each of them a lambda in second position may be either the parent prepareFunc or the RelatedEntityBuilder callback — e.Related(x => x.Lines, r => r.ServerOwned(x => x.UnitPrice)). A non-int related key needs the 2-arg e.Related<TRelated, TRelatedKey>(…), where the second position is always prepareFunc: name the callback, configure: r => ….

HasAttachments is an extension on the base EntityServiceBuilder (Regira.Entities.DependencyInjection.Attachments), so it applies on every tier — a complex owner chains .HasAttachments(...) exactly like a simple one. WithAttachments is an instance method on the EntityServiceCollection<TContext> that UseEntities returns (registered once for the shared Attachment, independent of any owner’s tier). (§Attachments)

Top-Level DI Entry Point

using Regira.Entities.DependencyInjection.Extensions;

public static EntityServiceCollection<TContext> UseEntities<TContext>(
    this IServiceCollection services,
    Action<EntityServiceCollectionOptions>? configure = null)
    where TContext : DbContext;

EntityServiceCollectionOptions Extension Methods

Setup

using Regira.Entities.DependencyInjection.Extensions;

// Registers in one call: paging defaults (DefaultPageSize=10, MaxPageSize=100), default primers
// (AddDefaultPrimers()), the AutoServerOwnedPrepper, default global filters (AddDefaultGlobalQueryFilters()),
// and the default entity normalizer. Also calls AddDefaultInterceptors() (= WireDbContext(DbContextWiring.All)): UseEntities<TContext>()
// then wires the primer/normalizer/auto-truncate/reactor interceptors + UTC date convention into the DbContext options
// automatically (AddDbContext only needs the provider; assignability match — an abstract-base registration
// also wires derived provider-specific contexts, in any registration order). UTC date handling itself is on
// by default process-wide (DateTimeDefaults.UseUtc) — disable with UseUtc(false).
// The normalized-content Q filter (FilterHasNormalizedContentQueryBuilder)
// is added ONLY on the parameterless overload — UseDefaults(cfg => …) skips it, so register it yourself then.
public static EntityServiceCollectionOptions UseDefaults(
    this EntityServiceCollectionOptions options,
    Action<EntityDefaultNormalizingOptions>? configure = null);

public static EntityServiceCollectionOptions UseNormalizerDefaults(
    this EntityServiceCollectionOptions options,
    Action<EntityDefaultNormalizingOptions>? configure = null);

Mapping

// package: Regira.Entities.Mapping.Mapster
using Regira.Entities.Mapping.Mapster;

public static EntityServiceCollectionOptions UseMapsterMapping(
    this EntityServiceCollectionOptions options,
    Action<TypeAdapterConfig>? configure = null);
// package: Regira.Entities.Mapping.AutoMapper
using Regira.Entities.Mapping.AutoMapper;

public static EntityServiceCollectionOptions UseAutoMapper(
    this EntityServiceCollectionOptions options,
    Action<IServiceProvider, IMapperConfigurationExpression>? configure = null);
using Regira.Entities.DependencyInjection.Mapping;

public static EntityServiceCollectionOptions AddAfterMapper<TAfterMapper>(
    this EntityServiceCollectionOptions options)
    where TAfterMapper : class, IEntityAfterMapper;

public static EntityServiceCollectionOptions AfterMap<TSource, TTarget>(
    this EntityServiceCollectionOptions options,
    Action<TSource, TTarget> afterMapAction);

public static EntityServiceCollectionOptions AfterMap<TSource, TTarget>(
    this EntityServiceCollectionOptions options,
    Func<IServiceProvider, Action<TSource, TTarget>> afterMapAction);

Preppers (global)

using Regira.Entities.DependencyInjection.Preppers;

public static EntityServiceCollectionOptions AddPrepper<TImplementation>(
    this EntityServiceCollectionOptions options)
    where TImplementation : class, IEntityPrepper;

public static EntityServiceCollectionOptions AddPrepper<TEntity>(
    this EntityServiceCollectionOptions options,
    Action<TEntity> prepareFunc)
    where TEntity : class;

public static EntityServiceCollectionOptions AddPrepper<TContext, TEntity, TKey>(
    this EntityServiceCollectionOptions options,
    Func<TEntity, TContext, Task> prepareFunc)
    where TContext : DbContext
    where TEntity : class, IEntity<TKey>;

Validators (global)

using Regira.Entities.DependencyInjection.Validators;

// A validator class for every entity its IEntityValidator<TScope> covers — an interface or base class reaches every
// entity implementing it; one implementing only IEntityValidator checks every entity. A class registered twice runs once.
public static EntityServiceCollectionOptions AddValidator<TValidator>(
    this EntityServiceCollectionOptions options)
    where TValidator : class, IEntityValidator;

public static EntityServiceCollectionOptions AddValidator<TScope>(
    this EntityServiceCollectionOptions options,
    Action<IEntityValidatorContext<TScope>> validate)
    where TScope : class;
// async ctx => … binds here, and the write awaits it
public static EntityServiceCollectionOptions AddValidator<TScope>(
    this EntityServiceCollectionOptions options,
    Func<IEntityValidatorContext<TScope>, Task> validate)
    where TScope : class;

// receives the request's DbContext (unfiltered) and the write's cancellation token for its queries
public static EntityServiceCollectionOptions AddValidator<TContext, TScope>(
    this EntityServiceCollectionOptions options,
    Func<IEntityValidatorContext<TScope>, TContext, CancellationToken, Task> validate)
    where TContext : DbContext
    where TScope : class;

// IServiceCollection forms — the same scope rule
public static IServiceCollection AddValidator<TValidator>(this IServiceCollection services)
    where TValidator : class, IEntityValidator;
public static IServiceCollection AddValidator<TScope>(this IServiceCollection services,
    Action<IEntityValidatorContext<TScope>> validate) where TScope : class;
public static IServiceCollection AddValidator<TScope>(this IServiceCollection services,
    Func<IEntityValidatorContext<TScope>, Task> validate) where TScope : class;
public static IServiceCollection AddValidator<TContext, TScope>(this IServiceCollection services,
    Func<IEntityValidatorContext<TScope>, TContext, CancellationToken, Task> validate) where TContext : DbContext where TScope : class;

Primers (global)

using Regira.Entities.DependencyInjection.Primers;

public static EntityServiceCollectionOptions AddPrimer<TPrimer>(
    this EntityServiceCollectionOptions options)
    where TPrimer : class, IEntityPrimer;

// Registers ArchivablePrimer + HasCreatedDbPrimer + HasLastModifiedDbPrimer + HasConcurrencyTokenDbPrimer
public static EntityServiceCollectionOptions AddDefaultPrimers(
    this EntityServiceCollectionOptions options);

Reactors (global)

using Regira.Entities.DependencyInjection.Reactors;

// Reacts to every entity its IEntityReactor<T> covers (an interface or base type reaches every implementing
// entity); a reactor implementing only IEntityReactor reacts to every entity.
public static EntityServiceCollectionOptions AddReactor<TReactor>(
    this EntityServiceCollectionOptions options)
    where TReactor : class, IEntityReactor;

// IServiceCollection forms
public static IServiceCollection AddReactor<TReactor>(this IServiceCollection services)
    where TReactor : class, IEntityReactor;
public static IServiceCollection AddReactor<TEntity, TReactor>(this IServiceCollection services)
    where TReactor : class, IEntityReactor<TEntity>;   // reacts to TEntity only
public static IServiceCollection AddReactor<TEntity>(this IServiceCollection services,
    Func<IServiceProvider, IEntityReactor<TEntity>> factory);   // factory gets the reaction's own scope

Global Filter Query Builders

using Regira.Entities.DependencyInjection.QueryBuilders;

public static EntityServiceCollectionOptions AddGlobalFilterQueryBuilder<TImplementation>(
    this EntityServiceCollectionOptions options)
    where TImplementation : class, IGlobalFilteredQueryBuilder;

// Registers FilterIdsQueryBuilder + FilterArchivablesQueryBuilder
//   + FilterHasCreatedQueryBuilder + FilterHasLastModifiedQueryBuilder
public static EntityServiceCollectionOptions AddDefaultGlobalQueryFilters(
    this EntityServiceCollectionOptions options);

// Removes every global filter registered before it, UseDefaults()' among them — the archived filter too.
// On IServiceCollection, so it chains off the UseEntities<TContext>() builder; not on the options.
public static TServiceCollection RemoveGlobalQueryFilters<TServiceCollection>(this TServiceCollection services)
    where TServiceCollection : IServiceCollection;

Normalizers (global)

using Regira.Entities.DependencyInjection.Normalizers;

public static EntityServiceCollectionOptions AddNormalizer<TNormalizer>(
    this EntityServiceCollectionOptions options)
    where TNormalizer : class, IEntityNormalizer;

public static EntityServiceCollectionOptions AddNormalizer<TEntity, TNormalizer>(
    this EntityServiceCollectionOptions options)
    where TNormalizer : class, IEntityNormalizer<TEntity>;

// Registers DefaultNormalizer + ObjectNormalizer + DefaultEntityNormalizer + QKeywordHelper
public static EntityServiceCollectionOptions AddDefaultEntityNormalizer(
    this EntityServiceCollectionOptions options,
    Action<NormalizeOptions>? configure = null);

EntityServiceCollection<TContext>

using Regira.Entities.DependencyInjection.ServiceCollections;
using Regira.Entities.DependencyInjection.Attachments;   // HasAttachments, the extension below

public class EntityServiceCollection<TContext>
    where TContext : DbContext
{
    // Simple entity (int key, default SearchObject)
    EntityServiceCollection<TContext> For<TEntity>(
        Action<EntityIntServiceBuilder<TContext, TEntity>>? configure = null)
        where TEntity : class, IEntity<int>;

    // Entity with custom TKey
    EntityServiceCollection<TContext> For<TEntity, TKey>(
        Action<EntityServiceBuilder<TContext, TEntity, TKey>>? configure = null)
        where TEntity : class, IEntity<TKey>;

    // Entity with custom TKey + SearchObject
    EntityServiceCollection<TContext> For<TEntity, TKey, TSearchObject>(
        Action<EntitySearchObjectServiceBuilder<TContext, TEntity, TKey, TSearchObject>>? configure = null)
        where TEntity : class, IEntity<TKey>
        where TSearchObject : class, ISearchObject<TKey>, new();

    // Full-featured (int key) — most common
    EntityServiceCollection<TContext> For<TEntity, TSearchObject, TSortBy, TIncludes>(
        Action<ComplexEntityIntServiceBuilder<TContext, TEntity, TSearchObject, TSortBy, TIncludes>>? configure = null)
        where TEntity : class, IEntity<int>
        where TSearchObject : class, ISearchObject<int>, new()
        where TSortBy : struct, Enum
        where TIncludes : struct, Enum;

    // Full-featured (with TKey)
    EntityServiceCollection<TContext> For<TEntity, TKey, TSearchObject, TSortBy, TIncludes>(
        Action<ComplexEntityServiceBuilder<TContext, TEntity, TKey, TSearchObject, TSortBy, TIncludes>>? configure = null)
        where TEntity : class, IEntity<TKey>
        where TSearchObject : class, ISearchObject<TKey>, new()
        where TSortBy : struct, Enum
        where TIncludes : struct, Enum;

    // Attachments — register the shared Attachment entity + file store + bytes→file primer.
    // Framework infrastructure: the shared Attachment base is registered once and reused by every owner.
    EntityServiceCollection<TContext> WithAttachments(
        Func<IServiceProvider, IFileService> factory,
        Action<EntitySearchObjectServiceBuilder<TContext, Attachment, int, AttachmentSearchObject<int>>>? configure = null);

    // Generic service helpers
    EntityServiceCollection<TContext> AddTransient<TService, TImplementation>()
        where TService : class
        where TImplementation : class, TService;

    EntityServiceCollection<TContext> AddTransient<TService>(
        Func<IServiceProvider, TService> factory)
        where TService : class;
}

// Typed per-owner attachment registration — extension on the For<>() builder
// (Regira.Entities.DependencyInjection.Attachments.EntityServiceBuilderExtensions).
// Registers the typed read/write services, the link prepper, RelatedAttachments and DTO mapping,
// plus a per-owner join entity (e.g. ProductAttachment). For slot cost see §License requirement.
// Usage: services.For<Product>(e => e.HasAttachments<AppDbContext, Product, ProductAttachment>(x => x.Attachments));
public static class EntityServiceBuilderExtensions
{
    public static EntityAttachmentServiceBuilder<TContext, TEntity, int, TEntityAttachment, int, EntityAttachmentSearchObject, int, Attachment>
        HasAttachments<TContext, TEntity, TEntityAttachment>(
            this EntityServiceBuilder<TContext, TEntity, int> builder,
            Expression<Func<TEntity, ICollection<TEntityAttachment>?>> navigationExpression,
            Action<EntityAttachmentServiceBuilder<TContext, TEntity, int, TEntityAttachment, int, EntityAttachmentSearchObject, int, Attachment>>? configure = null)
        where TContext : DbContext
        where TEntity : class, IEntity<int>, IHasAttachments<TEntityAttachment>
        where TEntityAttachment : class, IEntityAttachment<int, int, int, Attachment>, new();
}

EntityServiceBuilder<TContext, TEntity, TKey>

Base builder. Derives from EntityServiceCollection<TContext> (above), so inside a .For<>(e => …) lambda e also offers that type’s generic AddTransient(...) registration helpers — e.g. e.AddTransient<IOrderService, OrderManager>() — alongside the builder methods below.

using Regira.Entities.DependencyInjection.ServiceBuilders;

public partial class EntityServiceBuilder<TContext, TEntity, TKey> : EntityServiceCollection<TContext>
    where TContext : DbContext
    where TEntity : class, IEntity<TKey>
{
    bool HasEntityService();
    bool HasService<TService>();

    // Elevate to typed search object builder
    EntitySearchObjectServiceBuilder<TContext, TEntity, TKey, TSearchObject>
        WithSearchObject<TSearchObject>()
        where TSearchObject : class, ISearchObject<TKey>, new();

    // Mapping
    MappedEntityServiceBuilder<TContext, TEntity, TKey, TDto, TInputDto>
        UseMapping<TDto, TInputDto>(Action<IEntityMapConfigurator>? mapAction = null);

    // Escape hatch — register an explicit Mapster config for a single type pair.
    // NOT required to project nested DTOs / Related() collections: Mapster maps similar models
    // (incl. nested children) by convention. Add a pair only when convention gets it wrong —
    // e.g. a child DTO whose shape diverges, or a child InputDto that needs a custom mapping.
    // (See entities.instructions.md §Step 10. An empty nested collection is usually a missing
    //  Includes, not a missing mapping.)
    EntityServiceBuilder<TContext, TEntity, TKey> AddMapping<TSource, TTarget>();

    // Service registration
    EntityServiceBuilder<TContext, TEntity, TKey> AddDefaultService();

    // Registers IEntityService<TEntity,TKey> + IEntityService<TEntity,TKey,SearchObject<TKey>>.
    // Note: does NOT register the bare IEntityService<TEntity> shortcut — that is only registered by
    // For<TEntity>() and the complex int For<TEntity,TSearchObject,TSortBy,TIncludes>() overloads.
    EntityServiceBuilder<TContext, TEntity, TKey> UseEntityService<TService>()
        where TService : class, IEntityService<TEntity, TKey>, IEntityService<TEntity, TKey, SearchObject<TKey>>;

    EntityServiceBuilder<TContext, TEntity, TKey> UseEntityService<TService>(
        Func<IServiceProvider, TService> factory)
        where TService : class, IEntityService<TEntity, TKey, SearchObject<TKey>>;

    EntityServiceBuilder<TContext, TEntity, TKey> UseReadService<TService>()
        where TService : class, IEntityReadService<TEntity, TKey, SearchObject<TKey>>;

    EntityServiceBuilder<TContext, TEntity, TKey> UseWriteService<TService>()
        where TService : class, IEntityWriteService<TEntity, TKey>;

    EntityServiceBuilder<TContext, TEntity, TKey> HasRepository<TService>()
        where TService : class, IEntityRepository<TEntity, TKey, SearchObject<TKey>>;

    EntityServiceBuilder<TContext, TEntity, TKey> HasRepository<TImplementation>(
        Func<IServiceProvider, TImplementation> factory)
        where TImplementation : class, IEntityRepository<TEntity, TKey>, IEntityRepository<TEntity, TKey, SearchObject<TKey>>;

    EntityServiceBuilder<TContext, TEntity, TKey> HasManager<TService>()
        where TService : class, IEntityManager<TEntity, TKey>, IEntityManager<TEntity, TKey, SearchObject<TKey>>;

    EntityServiceBuilder<TContext, TEntity, TKey> HasManager<TImplementation>(
        Func<IServiceProvider, TImplementation> factory)
        where TImplementation : class, IEntityManager<TEntity, TKey>, IEntityManager<TEntity, TKey, SearchObject<TKey>>;

    // Query builders
    EntityServiceBuilder<TContext, TEntity, TKey> AddDefaultQueryBuilder();

    EntityServiceBuilder<TContext, TEntity, TKey> UseQueryBuilder<TImplementation>()
        where TImplementation : class, IQueryBuilder<TEntity, TKey, SearchObject<TKey>, EntitySortBy, EntityIncludes>;

    EntityServiceBuilder<TContext, TEntity, TKey> UseQueryBuilder<TImplementation>(
        Func<IServiceProvider, TImplementation> factory)
        where TImplementation : class, IQueryBuilder<TEntity, TKey, SearchObject<TKey>, EntitySortBy, EntityIncludes>;

    EntityServiceBuilder<TContext, TEntity, TKey> AddFilter<TImplementation>()
        where TImplementation : class, IFilteredQueryBuilder<TEntity, TKey, SearchObject<TKey>>;

    EntityServiceBuilder<TContext, TEntity, TKey> AddFilter<TImplementation>(
        Func<IServiceProvider, TImplementation> factory)
        where TImplementation : class, IFilteredQueryBuilder<TEntity, TKey, SearchObject<TKey>>;

    EntityServiceBuilder<TContext, TEntity, TKey> Filter(
        Func<IQueryable<TEntity>, SearchObject<TKey>?, IQueryable<TEntity>> filterFunc);

    // Sorting / includes (typed to EntitySortBy / EntityIncludes at this level)
    EntityServiceBuilder<TContext, TEntity, TKey> SortBy(
        Func<IQueryable<TEntity>, IQueryable<TEntity>> sortBy);

    EntityServiceBuilder<TContext, TEntity, TKey> Includes(
        Func<IQueryable<TEntity>, EntityIncludes?, IQueryable<TEntity>> addIncludes);

    // Per-entity paging override (HTTP boundary). null = off; SetPageSize() opts out entirely.
    EntityServiceBuilder<TContext, TEntity, TKey> SetPageSize(
        int? defaultPageSize = null, int? maxPageSize = null);

    // Per-entity read-path override (fully replaces the global EntityReadOptions for this entity):
    // whether save endpoints re-fetch the entity via Details(id) for the response.
    EntityServiceBuilder<TContext, TEntity, TKey> SetReadBehavior(
        RefetchAfterSave refetchAfterSave = RefetchAfterSave.Always);

    EntityServiceBuilder<TContext, TEntity, TKey> AddPrimer<TPrimer>()
        where TPrimer : class, IEntityPrimer<TEntity>;

    // Normalizers
    EntityServiceBuilder<TContext, TEntity, TKey> AddNormalizer<TNormalizer>()
        where TNormalizer : class, IEntityNormalizer<TEntity>;

    // Processors
    EntityServiceBuilder<TContext, TEntity, TKey> Process(
        Func<IList<TEntity>, EntityIncludes?, Task> process);

    EntityServiceBuilder<TContext, TEntity, TKey> Process(
        Action<TEntity, EntityIncludes?> process);

    EntityServiceBuilder<TContext, TEntity, TKey> AddProcessor<TProcessor>()
        where TProcessor : class, IEntityProcessor<TEntity, EntityIncludes>;

    // Preppers
    // inline shortcuts:
    EntityServiceBuilder<TContext, TEntity, TKey> Prepare(Action<TEntity> prepareFunc);

    EntityServiceBuilder<TContext, TEntity, TKey> Prepare(
        Func<TEntity, TContext, Task> prepareFunc);

    // class-based:
    EntityServiceBuilder<TContext, TEntity, TKey> AddPrepper<TPrepper>()
        where TPrepper : class, IEntityPrepper<TEntity>;

    // Validators — run after every prepper on Add/Modify/Save, and on Remove; one 400 for all their errors
    // inline (each call is a validator of its own; the write awaits an async one):
    EntityServiceBuilder<TContext, TEntity, TKey> Validate(Action<IEntityValidatorContext<TEntity>> validate);
    // e.Validate(async ctx => …)
    EntityServiceBuilder<TContext, TEntity, TKey> Validate(Func<IEntityValidatorContext<TEntity>, Task> validate);

    // e.Validate(async (ctx, db, token) => …) with the request's DbContext — unfiltered: check a row-secured reference
    // through IEntityReadService<,> in a class validator — and the write's cancellation token
    EntityServiceBuilder<TContext, TEntity, TKey> Validate(
        Func<IEntityValidatorContext<TEntity>, TContext, CancellationToken, Task> validate);

    // class-based: a validator scoped to an interface or base class TEntity is in checks EVERY entity in that
    // scope, not only this one (options.AddValidator<T>() is its place)
    EntityServiceBuilder<TContext, TEntity, TKey> AddValidator<TValidator>()
        where TValidator : class, IEntityValidator<TEntity>;

    // Server-owned scalar/FK: restored from the stored row on update, minted on create when
    // mintOnCreate is supplied and the property is unset. [ServerOwned] is the protect-only
    // attribute form (no registration needed once UseDefaults() has run).
    EntityServiceBuilder<TContext, TEntity, TKey> ServerOwned<TProp>(
        Expression<Func<TEntity, TProp>> selector,
        Func<TEntity, TProp>? mintOnCreate = null);

    // Primers
    // inline shortcuts:
    EntityServiceBuilder<TContext, TEntity, TKey> Prime(Action<TEntity> primeFunc);

    EntityServiceBuilder<TContext, TEntity, TKey> Prime(
        Func<TEntity, EntityEntry, TContext, Task> primeFunc);

    // class-based: AddPrimer<TPrimer>() (see above)

    // Reactors — run once a save that changed a TEntity row is committed, in a DI scope of their own;
    // reactFunc receives the change and that scope's IServiceProvider.
    EntityServiceBuilder<TContext, TEntity, TKey> React(
        Func<IEntityChange<TEntity>, IServiceProvider, CancellationToken, Task> reactFunc);

    EntityServiceBuilder<TContext, TEntity, TKey> React(
        Func<IEntityChange<TEntity>, bool> canReact,
        Func<IEntityChange<TEntity>, IServiceProvider, CancellationToken, Task> reactFunc);

    // when the save brought property to value (ChangedTo); an invalid selector throws ArgumentException here
    EntityServiceBuilder<TContext, TEntity, TKey> React<TProp>(
        Expression<Func<TEntity, TProp>> property, TProp value,
        Func<IEntityChange<TEntity>, IServiceProvider, CancellationToken, Task> reactFunc);

    // reacts to TEntity only — also a reactor written against an interface (options.AddReactor<T>() for all it covers)
    EntityServiceBuilder<TContext, TEntity, TKey> AddReactor<TReactor>()
        where TReactor : class, IEntityReactor<TEntity>;

    // Related child collections (managed by RelatedCollectionPrepper).
    // prepareFunc = optional parent-level prepare; configure = optional RelatedEntityBuilder
    // callback to nest sub-collections or add per-item prepare logic.
    EntityServiceBuilder<TContext, TEntity, TKey> Related<TRelated, TRelatedKey>(
        Expression<Func<TEntity, ICollection<TRelated>?>> navigationExpression,
        Action<TEntity>? prepareFunc = null,
        Action<RelatedEntityBuilder<TContext, TRelated, TRelatedKey>>? configure = null)
        where TRelated : class, IEntity<TRelatedKey>;

    void Build();
}

RelatedEntityBuilder<TContext, TRelated, TRelatedKey>

Passed into the configure callback of the Related(...) overload. Allows configuring nested sub-collections and per-item prepare logic for a related collection.

using Regira.Entities.DependencyInjection.ServiceBuilders;

public class RelatedEntityBuilder<TContext, TRelated, TRelatedKey>
    where TContext : DbContext
    where TRelated : class, IEntity<TRelatedKey>
{
    // Nest a sub-collection (generic key)
    RelatedEntityBuilder<TContext, TRelated, TRelatedKey> Related<TSubRelated, TSubRelatedKey>(
        Expression<Func<TRelated, ICollection<TSubRelated>?>> navigationExpression,
        Action<RelatedEntityBuilder<TContext, TSubRelated, TSubRelatedKey>>? configure = null)
        where TSubRelated : class, IEntity<TSubRelatedKey>;

    // Int-key shortcut for sub-collections
    RelatedEntityBuilder<TContext, TRelated, TRelatedKey> Related<TSubRelated>(
        Expression<Func<TRelated, ICollection<TSubRelated>?>> navigationExpression,
        Action<RelatedEntityBuilder<TContext, TSubRelated, int>>? configure = null)
        where TSubRelated : class, IEntity<int>;

    // Add a prepare action applied to each item in the collection
    RelatedEntityBuilder<TContext, TRelated, TRelatedKey> Prepare(Action<TRelated> prepareFunc);

    // Server-owned scalar/FK on the child (a line's UnitPrice) - same semantics as the parent's
    RelatedEntityBuilder<TContext, TRelated, TRelatedKey> ServerOwned<TProp>(
        Expression<Func<TRelated, TProp>> selector,
        Func<TRelated, TProp>? mintOnCreate = null);
}

EntitySearchObjectServiceBuilder

Returned by WithSearchObject<TSearchObject>(). Inherits all EntityServiceBuilder methods. Only listing new / changed members:

public partial class EntitySearchObjectServiceBuilder<TContext, TEntity, TKey, TSearchObject>
    : EntityServiceBuilder<TContext, TEntity, TKey>
    where TSearchObject : class, ISearchObject<TKey>, new()
{
    // NEW: elevate to full-featured builder
    ComplexEntityServiceBuilder<TContext, TEntity, TKey, TSearchObject, TSortBy, TIncludes>
        Complex<TSortBy, TIncludes>()
        where TSortBy : struct, Enum
        where TIncludes : struct, Enum;

    // CHANGED: constraint uses TSearchObject instead of SearchObject<TKey>
    EntitySearchObjectServiceBuilder<...> UseEntityService<TService>()
        where TService : class, IEntityService<TEntity, TKey, TSearchObject>;

    EntitySearchObjectServiceBuilder<...> AddFilter<TImplementation>()
        where TImplementation : class, IFilteredQueryBuilder<TEntity, TKey, TSearchObject>;

    EntitySearchObjectServiceBuilder<...> AddFilter<TImplementation>(
        Func<IServiceProvider, TImplementation> factory)
        where TImplementation : class, IFilteredQueryBuilder<TEntity, TKey, TSearchObject>;

    EntitySearchObjectServiceBuilder<...> Filter(
        Func<IQueryable<TEntity>, TSearchObject?, IQueryable<TEntity>> filterFunc);

    // Re-declared to keep the builder type through a chain, here and on the complex builders:
    // Validate(...) and React(...) — every overload — AddValidator<T>(), AddReactor<T>() and ServerOwned(...)

    // NEW: single-type-arg Related shortcut for int-keyed children (related key is int,
    // independent of the parent TKey). Use the inherited Related<TRelated, TRelatedKey> for non-int related keys.
    EntitySearchObjectServiceBuilder<...> Related<TRelated>(
        Expression<Func<TEntity, ICollection<TRelated>?>> navigationExpression,
        Action<TEntity>? prepareFunc = null,
        Action<RelatedEntityBuilder<TContext, TRelated, int>>? configure = null)
        where TRelated : class, IEntity<int>;

    void Build();
}

No bare IEntityService<TEntity> for this builder. For<TEntity, int, TSearchObject>() registers IEntityService<TEntity, int> and IEntityService<TEntity, int, TSearchObject> — not the bare IEntityService<TEntity> shortcut. Inject/resolve IEntityService<TEntity, int, TSearchObject> (see the Inject-as table in entities.instructions.md). The bare shortcut exists only for For<TEntity>() and the complex int For<TEntity, TSearchObject, TSortBy, TIncludes>() overloads.


Int-Key Variants

// For<TEntity>() → EntityIntServiceBuilder
public partial class EntityIntServiceBuilder<TContext, TEntity>
    : EntityServiceBuilder<TContext, TEntity, int>
    where TEntity : class, IEntity<int>
{
    // Advance to SearchObject variant
    EntityIntServiceBuilder<TContext, TEntity, TSearchObject> WithSearchObject<TSearchObject>()
        where TSearchObject : class, ISearchObject<int>, new();

    // Int-key shortcuts (no TRelatedKey / TContext parameter needed)
    EntityIntServiceBuilder<TContext, TEntity> Prepare(Func<TEntity, TContext, Task> prepareFunc);

    // Re-declared to keep the builder type through a chain
    EntityIntServiceBuilder<TContext, TEntity> Validate(Action<IEntityValidatorContext<TEntity>> validate);
    EntityIntServiceBuilder<TContext, TEntity> Validate(Func<IEntityValidatorContext<TEntity>, Task> validate);
    EntityIntServiceBuilder<TContext, TEntity> Validate(Func<IEntityValidatorContext<TEntity>, TContext, CancellationToken, Task> validate);
    EntityIntServiceBuilder<TContext, TEntity> AddValidator<TValidator>()
        where TValidator : class, IEntityValidator<TEntity>;
    // React(...) — every overload — and AddReactor<TReactor>() likewise return EntityIntServiceBuilder<TContext, TEntity>

    // Re-declared to keep the builder type through a chain — without it the next call falls back to
    // the base Related<TRelated, TRelatedKey>, whose key argument cannot be inferred (CS0411).
    EntityIntServiceBuilder<TContext, TEntity> ServerOwned<TProp>(
        Expression<Func<TEntity, TProp>> selector,
        Func<TEntity, TProp>? mintOnCreate = null);

    // Int-key shortcuts: sync only, or with a configure callback. For a parent-level prepare use
    // the inherited Related<TRelated, int>(nav, prepareFunc) or a separate e.Prepare(...).
    EntityIntServiceBuilder<TContext, TEntity> Related<TRelated>(
        Expression<Func<TEntity, ICollection<TRelated>?>> navigationExpression)
        where TRelated : class, IEntity<int>;

    EntityIntServiceBuilder<TContext, TEntity> Related<TRelated>(
        Expression<Func<TEntity, ICollection<TRelated>?>> navigationExpression,
        Action<RelatedEntityBuilder<TContext, TRelated, int>> configure)
        where TRelated : class, IEntity<int>;

    void Build();
}

// For<TEntity, TSearchObject>() or WithSearchObject() → EntityIntServiceBuilder<TContext, TEntity, TSearchObject>
public class EntityIntServiceBuilder<TContext, TEntity, TSearchObject>
    : EntitySearchObjectServiceBuilder<TContext, TEntity, int, TSearchObject>
    where TEntity : class, IEntity<int>
    where TSearchObject : class, ISearchObject<int>, new()
{
    // Advance to full-featured
    ComplexEntityIntServiceBuilder<TContext, TEntity, TSearchObject, TSortBy, TIncludes>
        Complex<TSortBy, TIncludes>()
        where TSortBy : struct, Enum
        where TIncludes : struct, Enum;

    void Build();
}

ComplexEntityServiceBuilder

Returned by .For<TEntity, TKey, TSearchObject, TSortBy, TIncludes>() or .Complex<TSortBy, TIncludes>(). Inherits all EntitySearchObjectServiceBuilder methods. Only listing new / changed members:

public partial class ComplexEntityServiceBuilder<TContext, TEntity, TKey, TSearchObject, TSortBy, TIncludes>
    : EntitySearchObjectServiceBuilder<TContext, TEntity, TKey, TSearchObject>
    where TSortBy : struct, Enum
    where TIncludes : struct, Enum
{
    // CHANGED: constraints use full TSortBy/TIncludes
    ComplexEntityServiceBuilder<...> UseEntityService<TService>()
        where TService : class, IEntityService<TEntity, TKey, TSearchObject, TSortBy, TIncludes>;

    ComplexEntityServiceBuilder<...> UseReadService<TService>()
        where TService : class, IEntityReadService<TEntity, TKey, TSearchObject, TSortBy, TIncludes>;

    ComplexEntityServiceBuilder<...> HasRepository<TService>()
        where TService : class, IEntityRepository<TEntity, TKey, TSearchObject, TSortBy, TIncludes>, IEntityRepository<TEntity, TKey>;

    ComplexEntityServiceBuilder<...> HasManager<TService>()
        where TService : class, IEntityManager<TEntity, TKey, TSearchObject, TSortBy, TIncludes>;

    ComplexEntityServiceBuilder<...> UseQueryBuilder<TImplementation>()
        where TImplementation : class, IQueryBuilder<TEntity, TKey, TSearchObject, TSortBy, TIncludes>;

    // NEW: typed sorting
    ComplexEntityServiceBuilder<...> AddSortBy<TImplementation>()
        where TImplementation : class, ISortedQueryBuilder<TEntity, TKey, TSortBy>;

    ComplexEntityServiceBuilder<...> SortBy(
        Func<IQueryable<TEntity>, TSortBy?, IQueryable<TEntity>> sortByFunc);

    // NEW: typed includes
    ComplexEntityServiceBuilder<...> AddIncludes<TImplementation>()
        where TImplementation : class, IIncludableQueryBuilder<TEntity, TKey, TIncludes>;

    ComplexEntityServiceBuilder<...> Includes(
        Func<IQueryable<TEntity>, TIncludes?, IQueryable<TEntity>> addIncludes);

    // NEW: typed processors
    ComplexEntityServiceBuilder<...> Process(Func<IList<TEntity>, TIncludes?, Task> process);
    ComplexEntityServiceBuilder<...> Process(Action<TEntity, TIncludes?> process);
    ComplexEntityServiceBuilder<...> AddProcessor<TImplementation>()
        where TImplementation : class, IEntityProcessor<TEntity, TIncludes>;

    void Build();
}

ComplexEntityIntServiceBuilder

Returned by .For<TEntity, TSearchObject, TSortBy, TIncludes>(). Inherits all ComplexEntityServiceBuilder methods. Only addition vs parent:

public partial class ComplexEntityIntServiceBuilder<TContext, TEntity, TSearchObject, TSortBy, TIncludes>
    : ComplexEntityServiceBuilder<TContext, TEntity, int, TSearchObject, TSortBy, TIncludes>
{
    // Int-key shortcut — no TRelatedKey type parameter needed
    ComplexEntityIntServiceBuilder<...> Related<TRelated>(
        Expression<Func<TEntity, ICollection<TRelated>?>> navigationExpression,
        Action<TEntity>? prepareFunc = null,
        Action<RelatedEntityBuilder<TContext, TRelated, int>>? configure = null)
        where TRelated : class, IEntity<int>;

    void Build();
}

MappedEntityServiceBuilder

Returned by UseMapping<TDto, TInputDto>(). Inherits all builder methods.

⚠️ The class-based After<TImplementation>() overloads live on the base (untyped) variant and return it — TDto/TInputDto are lost, so the typed .After(...)/.AfterInput(...) shortcuts no longer compile after them (CS1061). Chain the typed shortcuts first, or keep both after-mappers inline.

// Base variant — any source/target after-mapper
public class MappedEntityServiceBuilder<TContext, TEntity, TKey>
    : EntityServiceBuilder<TContext, TEntity, TKey>
{
    MappedEntityServiceBuilder<TContext, TEntity, TKey> After<TImplementation>()
        where TImplementation : class, IEntityAfterMapper;

    MappedEntityServiceBuilder<TContext, TEntity, TKey> After<TImplementation>(
        Func<IServiceProvider, TImplementation> factory)
        where TImplementation : class, IEntityAfterMapper;

    MappedEntityServiceBuilder<TContext, TEntity, TKey> After<TSource, TTarget>(
        Action<TSource, TTarget> afterMapAction);

    MappedEntityServiceBuilder<TContext, TEntity, TKey> After<TSource, TTarget>(
        Func<IServiceProvider, Action<TSource, TTarget>> afterMapActionFactory);
}

// Typed variant — TDto and TInputDto known; shortcut After/AfterInput
public class MappedEntityServiceBuilder<TContext, TEntity, TKey, TDto, TInputDto>
    : MappedEntityServiceBuilder<TContext, TEntity, TKey>
{
    // Entity → TDto after-mapper
    MappedEntityServiceBuilder<TContext, TEntity, TKey, TDto, TInputDto> After(
        Action<TEntity, TDto> afterMapAction);

    // TInputDto → Entity after-mapper
    MappedEntityServiceBuilder<TContext, TEntity, TKey, TDto, TInputDto> AfterInput(
        Action<TInputDto, TEntity> afterMapAction);
}

Mapping and Processing

IEntityMapper

using Regira.Entities.Mapping.Abstractions;

public interface IEntityMapper
{
    TTarget Map<TTarget>(object source);
    TTarget Map<TSource, TTarget>(TSource source, TTarget target);
}

After Mappers

using Regira.Entities.Mapping.Abstractions;

public interface IEntityAfterMapper
{
    bool CanMap(object source);
    void AfterMap(object source, object target);
}

public interface IEntityAfterMapper<in TSource, in TTarget> : IEntityAfterMapper
{
    void AfterMap(TSource source, TTarget target);
}

// Inherit to create a custom after-mapper class
public abstract class EntityAfterMapperBase<TSource, TTarget> : IEntityAfterMapper<TSource, TTarget>
{
    public abstract void AfterMap(TSource source, TTarget target);
    public bool CanMap(object source);
}

Query Builders

using Regira.Entities.QueryBuilders.Abstractions;

public interface IFilteredQueryBuilder<TEntity, TKey, in TSearchObject>
    where TSearchObject : ISearchObject<TKey>
{
    IQueryable<TEntity> Build(IQueryable<TEntity> query, TSearchObject? so);
}

// Preferred base class — inherit and override Build().
public abstract class FilteredQueryBuilderBase<TEntity>
    : FilteredQueryBuilderBase<TEntity, SearchObject<int>>
    where TEntity : IEntity<int>;

public abstract class FilteredQueryBuilderBase<TEntity, TSearchObject>
    : FilteredQueryBuilderBase<TEntity, int, TSearchObject>
    where TEntity : IEntity<int>
    where TSearchObject : ISearchObject<int>;

public abstract class FilteredQueryBuilderBase<TEntity, TKey, TSearchObject>
    : IFilteredQueryBuilder<TEntity, TKey, TSearchObject>
    where TSearchObject : ISearchObject<TKey>
{
    public abstract IQueryable<TEntity> Build(IQueryable<TEntity> query, TSearchObject? so);
}

// 2-param shortcut defaults TSortBy to EntitySortBy
public interface ISortedQueryBuilder<TEntity, TKey> : ISortedQueryBuilder<TEntity, TKey, EntitySortBy>
    where TEntity : IEntity<TKey>;

public interface ISortedQueryBuilder<TEntity, TKey, TSortBy>
    where TEntity : IEntity<TKey>
    where TSortBy : struct, Enum
{
    IQueryable<TEntity> SortBy(IQueryable<TEntity> query, TSortBy? sortBy = null);
}

// 2-param shortcut defaults TIncludes to EntityIncludes
public interface IIncludableQueryBuilder<TEntity, TKey> : IIncludableQueryBuilder<TEntity, TKey, EntityIncludes>
    where TEntity : IEntity<TKey>;

public interface IIncludableQueryBuilder<TEntity, TKey, TIncludes>
    where TEntity : IEntity<TKey>
    where TIncludes : struct, Enum
{
    IQueryable<TEntity> AddIncludes(IQueryable<TEntity> query, TIncludes? includes = null);
}

Processors

using Regira.Entities.Processing.Abstractions;

public interface IEntityProcessor<TEntity, in TIncludes>
    where TIncludes : struct, Enum
{
    Task Process(IList<TEntity> items, TIncludes? includes, CancellationToken token = default);
}

Preppers

using Regira.Entities.Preppers.Abstractions;

public interface IEntityPrepper
{
    Task Prepare(object modified, object? original, CancellationToken token = default);
}

public interface IEntityPrepper<in TEntity> : IEntityPrepper
{
    Task Prepare(TEntity modified, TEntity? original, CancellationToken token = default);
}

Validators

using Regira.Entities.Validators.Abstractions;

public enum EntityWriteOperation { Add, Modify, Remove }   // a soft delete of an IArchivable is Remove

public interface IEntityValidatorContext
{
    object Item { get; }                          // after every prepper; Remove: the stored row (else the caller's instance)
    object? Original { get; }                     // the stored row on Modify; null on Add and Remove
    EntityWriteOperation Operation { get; }
    IReadOnlyList<EntityInputError> Errors { get; }   // what every validator of this write added so far
    // key: property path ("CustomerId", "Lines[0].Quantity"), "" = whole entity; message: a text, or a translation key
    // a client pairs with its messages; args: the values a translation fills in — new { max = 20 } or a dictionary
    void AddError(string key, string message, object? args = null);
}
public interface IEntityValidatorContext<out TEntity> : IEntityValidatorContext
{
    new TEntity Item { get; }
    new TEntity? Original { get; }
}

public interface IEntityValidator
{
    Task Validate(IEntityValidatorContext context, CancellationToken token = default);
}
// TScope: the entity, a base class or an interface — matched against the item's runtime type
// implement the typed Validate only: the untyped one forwards to it by default (a class implementing two scopes
// implements the untyped one itself)
public interface IEntityValidator<in TScope> : IEntityValidator
{
    Task Validate(IEntityValidatorContext<TScope> context, CancellationToken token = default);
}

// one scope: AddValidator refuses a subclass that implements IEntityValidator<T> for a second one, which it would never run
public abstract class EntityValidatorBase<TScope> : IEntityValidator<TScope> where TScope : class
{
    public virtual bool CanValidate(TScope item) => true;   // per-item opt-out
    public abstract Task Validate(IEntityValidatorContext<TScope> context, CancellationToken token = default);
}

// a validator scoped wider than what it checks (e.g. to IEntity): it runs only for the item types it covers, and startup
// validation counts it only for those
public interface ISelectiveEntityValidator : IEntityValidator
{
    bool Covers(Type entityType);
}

// Regira.Entities.Validators
// build one to unit-test a validator
public class EntityValidatorContext<TEntity>(EntityWriteOperation operation, TEntity item, TEntity? original = null)
    : IEntityValidatorContext<TEntity> where TEntity : class;

public static class EntityValidatorExtensions
{
    // runs every validator in scope of the item's runtime type, in order, against one context; throws one
    // EntityInputException<TEntity> holding all their errors — what a custom repository calls before it writes
    public static Task ValidateItem<TEntity>(this IEnumerable<IEntityValidator> validators, TEntity item,
        TEntity? original, EntityWriteOperation operation, CancellationToken token = default) where TEntity : class;
    // whether any validator runs for an item of itemType — when none does, ValidateItem cannot refuse
    public static bool AnyApplyTo(this IEnumerable<IEntityValidator> validators, Type itemType);
}

public static class EntityScopeTypes
{
    // the type itself, then every type it derives from or implements — the scope rule of validators and global filters
    public static IReadOnlyList<Type> Of(Type entityType);
}

// Regira.Entities.EFcore.Validators — what the builder's Validate(...) registers
public class EntityValidator<TScope>(Func<IEntityValidatorContext<TScope>, Task> validate)
    : EntityValidatorBase<TScope> where TScope : class
{
    public EntityValidator(Action<IEntityValidatorContext<TScope>> validate);
}
public class EntityValidator<TContext, TScope>(TContext dbContext, Func<IEntityValidatorContext<TScope>, TContext, CancellationToken, Task> validate)
    : EntityValidatorBase<TScope> where TContext : DbContext where TScope : class;

FluentValidation adapter — package Regira.Entities.Validation.FluentValidation:

using Regira.Entities.Validation.FluentValidation;

// inside UseEntities(): registers the assemblies' validators (scoped) and the FluentEntityValidator stage
public static EntityServiceCollectionOptions UseFluentValidation(
    this EntityServiceCollectionOptions options, params Assembly[] assemblies);

// Add/Modify run the rules outside any rule set plus Add/Modify; Remove runs Remove alone
public static class EntityRuleSets
{
    public const string Add = "Add";
    public const string Modify = "Modify";
    public const string Remove = "Remove";
}

public static class ValidationContextExtensions
{
    public static T? GetOriginal<T>(this ValidationContext<T> context);                  // the stored row on Modify
    public static EntityWriteOperation? GetOperation<T>(this ValidationContext<T> context); // null outside the pipeline
}

// an IEntityValidator<IEntity>: resolves IValidator<T> for every type in EntityScopeTypes.Of(item.GetType());
// only Severity.Error failures refuse the write; covers the entities an AbstractValidator applies to
public class FluentEntityValidator(IServiceProvider services) : EntityValidatorBase<IEntity>, ISelectiveEntityValidator;

Primers

using Regira.Entities.EFcore.Primers.Abstractions;

public interface IEntityPrimer
{
    Task PrepareManyAsync(IList<EntityEntry> entries, CancellationToken token = default);
    Task PrepareAsync(object entity, EntityEntry entry, CancellationToken token = default);
    bool CanPrepare(object entity);
}

public interface IEntityPrimer<in T> : IEntityPrimer
{
    Task PrepareAsync(T entity, EntityEntry entry, CancellationToken token = default);
    bool CanPrepare(T entity);
}

Reactors

using Regira.Entities.Reactors.Abstractions;

public enum EntityChangeKind { Added, Modified, Deleted }   // a soft delete is Modified (IsArchived → true)

public interface IEntityChange
{
    EntityChangeKind Kind { get; }
    object Entity { get; }                                // as committed; Deleted: as it was stored
    object? Original { get; }                             // as stored before the save; null for Added
    IReadOnlyCollection<string> ChangedProperties { get; }  // Modified only; complex members as "Address.City"
}
public interface IEntityChange<out TEntity> : IEntityChange
{
    new TEntity Entity { get; }
    new TEntity? Original { get; }
}

public interface IEntityReactor
{
    bool CanReact(IEntityChange change);
    Task React(IEntityChange change, CancellationToken token = default);
}
public interface IEntityReactor<in TEntity> : IEntityReactor
{
    bool CanReact(IEntityChange<TEntity> change);
    Task React(IEntityChange<TEntity> change, CancellationToken token = default);
}

public abstract class EntityReactorBase<TEntity> : IEntityReactor<TEntity> where TEntity : class
{
    public virtual bool CanReact(IEntityChange<TEntity> change) => true;
    public abstract Task React(IEntityChange<TEntity> change, CancellationToken token = default);
}

public static class EntityChangeExtensions
{
    // Modified with a committed value that differs from the stored one; a complex property matches any member
    public static bool HasChanged<TEntity, TProp>(this IEntityChange<TEntity> change,
        Expression<Func<TEntity, TProp>> property);
    // an insert holding value, or an update that brought the property to it; never a delete
    public static bool ChangedTo<TEntity, TProp>(this IEntityChange<TEntity> change,
        Expression<Func<TEntity, TProp>> property, TProp value);
}

// Regira.Entities.Reactors — build one to unit-test a reactor
public class EntityChange<TEntity>(EntityChangeKind kind, TEntity entity, TEntity? original = null,
    IReadOnlyCollection<string>? changedProperties = null) : IEntityChange<TEntity> where TEntity : class;

Normalizers

using Regira.Entities.Normalizing.Abstractions;

public interface IEntityNormalizer
{
    bool IsExclusive { get; }
    Task HandleNormalize(object item, CancellationToken token = default);
    Task HandleNormalizeMany(IEnumerable<object> items, CancellationToken token = default);
}

public interface IEntityNormalizer<in T> : IEntityNormalizer
{
    Task HandleNormalize(T item, CancellationToken token = default);
    Task HandleNormalizeMany(IEnumerable<T> items, CancellationToken token = default);
}

Keyword Parsing

using Regira.Entities.Keywords;
using Regira.Entities.Keywords.Abstractions;

public interface IQKeywordHelper
{
    ParsedKeywordCollection Parse(string? input);   // splits on spaces — one QKeyword per term
    QKeyword ParseKeyword(string? input);           // a single term
}

public class QKeyword
{
    public string? Keyword { get; set; }            // unmodified input, wildcards included
    public bool HasWildcardAtStart { get; set; }
    public bool HasWildcardAtEnd { get; set; }
    public bool HasWildcard { get; }

    // RAW family — the input minus its wildcards, untouched otherwise
    public string? Trimmed { get; set; }
    public string? TrimmedStartsWith { get; set; }  // "term%"
    public string? TrimmedEndsWith { get; set; }    // "%term"
    public string? TrimmedQ { get; set; }           // wildcards the INPUT carried, e.g. "*term" → "%term"
    public string? TrimmedQW { get; set; }          // "%term%", always both ends

    // NORMALIZED family — same shapes, built from the normalized keyword
    public string? Normalized { get; set; }
    public string? StartsWith { get; set; }
    public string? EndsWith { get; set; }
    public string? Q { get; set; }
    public string? QW { get; set; }
}

Match the family to the column. Everything carrying the Trimmed prefix holds the raw keyword; everything without it holds the normalized one (Q/QW included). Pair a normalized column (NormalizedContent, NormalizedLastName) with the unprefixed members, and a column that stores the client’s value verbatim (FileName, a reference code) with the Trimmed* ones. Crossing them compiles and silently matches nothing — QKeywordHelper.ApplyNormalize defaults to true, and the default normalizer drops . and turns - into a space (case is preserved: Transform defaults to NoChanges), so no real file name survives it — my-report.pdf normalizes to my reportpdf. Note the flip side: because the Trimmed* members are raw, a % or _ the client typed keeps its SQL LIKE meaning and over-matches. The normalized family is narrower here, not exempt: the default normalizer’s allow-list ([^a-z0-9\s\-_,!;&']) deletes % but keeps _, so _ reaches Q/QW as a single-character wildcard too. Escape them if exact punctuation has to match.


Response Types

using Regira.Entities.Web.Models;

public record DetailsResult<TDto>  { public TDto Item { get; set; }        public long? Duration { get; set; } }
public record ListResult<TDto>     { public IList<TDto> Items { get; set; } public long? Duration { get; set; } }
public record SearchResult<TDto>   { public IList<TDto> Items { get; set; } public long Count { get; set; }     public long? Duration { get; set; } }
public record SaveResult<TDto>     { public TDto Item { get; set; }        public bool IsNew { get; set; }     public int Affected { get; set; }   public long? Duration { get; set; } }
public record DeleteResult<TDto>   { public TDto Item { get; set; }        public long? Duration { get; set; } }

Attachments

using Regira.Entities.Attachments.Abstractions;

public interface IAttachment : IBinaryFile, IHasTimestamps;
public interface IAttachment<TKey> : IAttachment, IEntity<TKey>;
// Members (via IBinaryFile -> IStorageFile -> INamedFile and IHasTimestamps):
//   string? FileName; string? Identifier; string? Prefix; string? Path;
//   string? ContentType; long Length; byte[]? Bytes; Stream? Stream;
//   DateTime Created; DateTime? LastModified
// A Uri property exists on EntityAttachmentDto (Regira.Entities.Mapping.Models), not on the entity.

public interface IEntityAttachment
{
    string? ObjectType { get; }

    string? NewFileName { get; set; }
    [Obsolete] string? NewContentType { get; set; }   // ignored: the content type follows the file name
    byte[]? NewBytes { get; set; }
    IAttachment? Attachment { get; set; }
}

public interface IEntityAttachment<TKey, TObjectKey> : IEntityAttachment<TKey, TObjectKey, int, Attachment>;
public interface IEntityAttachment<TKey, TObjectKey, TAttachmentKey> : IEntityAttachment<TKey, TObjectKey, TAttachmentKey, Attachment<TAttachmentKey>>;
public interface IEntityAttachment<TKey, TObjectKey, TAttachmentKey, TAttachment>
    : IEntity<TKey>, IHasObjectId<TObjectKey>, IEntityAttachment, ISortable
    where TAttachment : class, IAttachment<TAttachmentKey>, new()
{
    TAttachmentKey AttachmentId { get; set; }
    new TAttachment? Attachment { get; set; }
}

Attachment Controller

using Regira.Entities.Web.Attachments.Abstractions;

// Simplest variant
public abstract class EntityAttachmentControllerBase<TEntity>
    : EntityAttachmentControllerBase<TEntity, EntityAttachmentDto, EntityAttachmentInputDto>
    where TEntity : class, IEntityAttachment<int, int, int, Attachment>, IEntity<int>;

// Standard variant — set the class [Route] to the owner base path (e.g. [Route("products")]);
// the base actions append the sub-routes below.
public abstract class EntityAttachmentControllerBase<TEntity, TDto, TInputDto>
    : ControllerBase
    where TEntity : class, IEntityAttachment<int, int, int, Attachment>, IEntity<int>
    where TInputDto : class, IEntityAttachmentInput;

Every {id} is the link id (EntityAttachmentDto.Id), never attachmentId; {objectId} is the owner’s id.

Route (under the owner base path) Action
GET {objectId}/attachments the owner’s links (ListResult)
GET attachments · GET attachments/{id} links across owners (EntityAttachmentSearchObject) · one link
POST {objectId}/files upload — multipart file + the input DTO’s fields as form values; always creates a link, whatever id the form sends
PUT {objectId}/files/{id} replace the file’s bytes (multipart file)
PUT {objectId}/attachments/{id} update the link’s own fields (JSON input DTO); newFileName renames the file and retypes it, newBytes replaces its content. The link keeps its attachment, whatever attachmentId the body sends
DELETE attachments/{id} remove the link and its file
GET files/{id} · GET {objectId}/files/{*fileName} download by link id · by the client FileName (?inline=false → attachment)

A write a validator refuses answers 400 with a ValidationProblemDetails, and so do the two PUT routes for a link of another owner, keyed objectId. Validators scoped to the link entity run for these routes only: a PUT of the owner that syncs its Attachments runs the owner’s validators alone.


Exceptions

using Regira.Entities.Models;

// Throw the generic form; the base is what handlers catch. HTTP 400, from any MVC action.
public abstract class EntityInputException(string message, Exception? innerException = null)
    : Exception(message, innerException)
{
    // several messages per key: the 400 body is built from it (with none, Message goes out under the key "")
    public IList<EntityInputError> Errors { get; set; }          // pre-initialized
    // a view over Errors, one message per key (a key's messages joined by a space); setting a key replaces its
    // messages in Errors, so a key added to a caught rejection before it is rethrown reaches the 400; assigning a
    // dictionary copies its entries, and a later change to that dictionary does not reach the exception
    public IDictionary<string, string> InputErrors { get; set; }
}

// Key "" = the entity as a whole. Message: a text, or a translation key a client pairs with its messages. Args: the
// values a translation fills in; scalar values only reach the client (text, numbers, booleans, dates, times, Guids, enums)
public record EntityInputError(string Key, string Message, IReadOnlyDictionary<string, object?>? Args = null);

public class EntityInputException<T>(string message, Exception? innerException = null)
    : EntityInputException(message, innerException)
{
    public T? Item { get; set; }
}

// HTTP 409, ProblemDetails title "Conflict": a database integrity constraint rejected the change.
public class EntityConstraintException(string message, Exception? innerException = null)
    : Exception(message, innerException)
{
    public const string ClientMessage = "A database constraint rejected the change.";
}

// HTTP 409, ProblemDetails title "Concurrency conflict": a stale concurrency token, or a row another writer
// removed. InnerException is EF Core's DbUpdateConcurrencyException (the conflicting rows are in Entries).
public class EntityConcurrencyException(string message, Exception? innerException = null)
    : Exception(message, innerException)
{
    public const string ClientMessage = "The record was changed or removed since it was read. Reload it and try again.";
}

ConfigureDefaultJsonOptions() registers the filter that maps them — 400 with Errors as a ValidationProblemDetails (errors, and errorDetails with each error’s args), 409 for EntityConstraintException and EntityConcurrencyException — so a hand-written action returns what the generated ones do. Catch the non-generic base if you handle it yourself: the generated write actions catch their own closed generic, which misses the one a prepper threw for a related entity (EntityInputException<Product> inside an Order write).

InputErrors is initialized, so both forms work — a nested initializer for a fixed set, indexer assignment for a map you build. An error with args goes into Errors itself. ctx.AddError(key, message, args?) in a validator is the other way in; all of them land in Errors:

throw new EntityInputException<Order>("Saving order failed")
{
    InputErrors = { ["OrderLines"] = "Order must contain at least one order line." }   // nested initializer, no `new`
};

var ex = new EntityInputException<Order>("Saving order failed");
foreach (var line in invalidLines)
    ex.InputErrors[$"OrderLines[{line.Index}].Quantity"] = "Must be greater than zero.";  // dynamic map
throw ex;

throw new EntityInputException<Order>("Saving order failed")
{
    Errors = { new EntityInputError(nameof(Order.Total), "ValueTooLarge", new Dictionary<string, object?> { ["max"] = 10_000 }) }
};

Supporting Types

using Regira.DAL.Paging;

public record PagingInfo
{
    public int PageSize { get; set; }
    public int Page { get; set; } = 1;
}
using Regira.Entities.DependencyInjection.ServiceCollections.Models;

public class EntityServiceCollectionOptions(IServiceCollection services)
{
    public IServiceCollection Services { get; }

    // Global list/paging defaults applied at the HTTP boundary (null = off).
    public int? DefaultPageSize { get; set; } // forced page size when the request omits paging
    public int? MaxPageSize { get; set; }     // upper limit a requested page size is clamped to

    // Query-pipeline behavior (see EntityQueryOptions below): the archived scope applied
    // when a search object leaves Archived null. Default Excluded.
    public ArchivedFilter DefaultArchivedFilter { get; set; }

    // UTC handling of entity DateTime values — one policy per process (Regira.Utilities.DateTimeDefaults.UseUtc,
    // on by default): primers write UtcNow, filters normalize input dates, and the UTC convention's converter
    // follows the same policy. UseUtc(false) → DateTime.Now and values are used as given.
    public EntityServiceCollectionOptions UseUtc(bool enabled = true);

    // The DbContext plumbing UseEntities<TContext>() auto-wires into the context's options.
    // UseDefaults() → All; à la carte without UseDefaults(): e.g. WireDbContext(DbContextWiring.PrimerInterceptors);
    // WireDbContext(DbContextWiring.None) → wire the DbContext yourself.
    public DbContextWiring DbContextWiring { get; set; }
    public EntityServiceCollectionOptions WireDbContext(DbContextWiring wiring = DbContextWiring.All);
    // Shorthand for WireDbContext(DbContextWiring.All) — the full default plumbing; called by UseDefaults()
    public EntityServiceCollectionOptions AddDefaultInterceptors();

    // Replaces the default EntityRepository for every For<>() that names no repository of its own.
    // Open generic types, matched to a For<>() shape by their number of type parameters (1–5).
    // e.g. UseRepository(typeof(AppRepository<>), typeof(AppRepository<,>))
    public EntityServiceCollectionOptions UseRepository(params Type[] repositoryTypes);

    // Startup validation (arity mismatches, unwired interceptors, ignored ?q=, competing write authorities,
    // null attachment Uri, out-of-scope global filters, missing archived query filter, archivable reference
    // data behind a required FK, attachments the input DTO cannot carry, For<>() shapes UseRepository() has no
    // type for). Development-only by default.
    public EntityServiceCollectionOptions ConfigureValidation(Action<EntityValidationOptions> configure);
}

[Flags]
public enum DbContextWiring
{
    None = 0,
    PrimerInterceptors = 1 << 0,
    NormalizerInterceptors = 1 << 1,
    AutoTruncateInterceptors = 1 << 2,
    UtcDateTimeConvention = 1 << 3,
    // e => !e.IsArchived on every IArchivable entity type — soft delete without a DbContext change
    ArchivedQueryFilter = 1 << 4,
    // IHasConcurrencyToken.ConcurrencyToken declared a concurrency token — without a DbContext change
    ConcurrencyTokens = 1 << 5,
    // runs the registered IEntityReactor implementations once a save's changes are committed
    Reactors = 1 << 6,
    All = PrimerInterceptors | NormalizerInterceptors | AutoTruncateInterceptors | UtcDateTimeConvention
        | ArchivedQueryFilter | ConcurrencyTokens | Reactors
}
using Regira.Entities.DependencyInjection.Validation;

public class EntityValidationOptions
{
    public bool? Enabled { get; set; }      // null = Development only; true = always; false = never
    public bool ThrowOnError { get; set; }  // default true: error-severity issues stop the host
}

// Entities.Web — registers the controller ↔ For<>() arity startup check and the controllers' DTO shapes
// (also enabled automatically by UseEntities())
public static EntityServiceCollectionOptions ValidateEntityControllers(this EntityServiceCollectionOptions options);
public static IServiceCollection ValidateEntityControllers(this IServiceCollection services);
using Regira.Entities.Models;

// Runtime carrier for the list/paging defaults; resolved by the controller List/Search endpoints.
public class EntityListOptions
{
    public int? DefaultPageSize { get; set; }
    public int? MaxPageSize { get; set; }
}

// Per-entity override, registered via e.SetPageSize(...). When present it fully replaces the
// global EntityListOptions for that entity (each null aspect is off).
public class EntityListOptions<TEntity> : EntityListOptions where TEntity : class;

// Which archived rows a read returns. ISearchObject.Archived binds this from ?archived=.
public enum ArchivedFilter
{
    Excluded = 0, // non-archived only
    Included,     // archived and non-archived alike
    Only          // archived only — the recycle bin
}

// Runtime carrier for query-pipeline behavior; registered as a singleton by UseEntities().
public class EntityQueryOptions
{
    // Applied when ISearchObject.Archived is null.
    public ArchivedFilter DefaultArchivedFilter { get; set; }
}

// Read-path behavior. Global via UseEntities(o => { o.RefetchAfterSave = ...; }), or per-entity via
// e.SetReadBehavior(...). Details(id) always loads all registered includes; the web save endpoints
// re-fetch per RefetchAfterSave.
public enum RefetchAfterSave { Always = 0, WhenProcessorsRegistered = 1, Never = 2 }  // Always = current default
public class EntityReadOptions
{
    public RefetchAfterSave RefetchAfterSave { get; set; }
}
public class EntityReadOptions<TEntity> : EntityReadOptions where TEntity : class;  // per-entity override

// DTO wire-shape descriptor recorded by UseMapping<TDto, TInputDto>(); registered as a singleton so
// endpoint scanners and other infrastructure can resolve the wire shape configured for an entity.
public sealed record EntityMappingRegistration(Type EntityType, Type DtoType, Type InputDtoType);

See Also