The IEntityService is the core service interface for managing entities. It provides standard CRUD operations and can be customized or extended as needed.
Possible combinations:
IEntityService<TEntity> // int ID
IEntityService<TEntity, TKey>
IEntityService<TEntity, TKey, TSearchObject>
IEntityService<TEntity, TSearchObject, TSortBy, TIncludes>
IEntityService<TEntity, TKey, TSearchObject, TSortBy, TIncludes>
EntityRepository, which uses EF Core DbContext for data accessEntityRepository is enriched by multiple helper services (QueryBuilders, Processors, Preppers, Primers, Reactors)UseEntityService with a custom implementation (e.g., CachedEntityService that adds caching on top of the repository)// Get single entity details by ID
Task<TEntity?> Details(TKey id, CancellationToken token = default)
// List with custom SearchObject (enhanced filtering)
Task<IList<TEntity>> List(TSearchObject? so = null, PagingInfo? pagingInfo = null, CancellationToken token = default)
// List with sorting and includes (complex filtering)
Task<IList<TEntity>> List(IList<TSearchObject?> so, IList<TSortBy> sortBy, TIncludes? includes = null, PagingInfo? pagingInfo = null, CancellationToken token = default)
// Count with custom (nullable) SearchObject
Task<long> Count(TSearchObject? so, CancellationToken token = default)
// Count with multiple SearchObjects
Task<long> Count(IList<TSearchObject?> so, CancellationToken token = default)
Paging at the service layer:
Listonly pages when you pass aPagingInfowith a positivePageSize; otherwise it returns the full set. The configurable default/maximum page size (DefaultPageSize/MaxPageSize, or per-entitye.SetPageSize(...)) is applied at the HTTP boundary (the MVC controllers), not here — so direct service calls keep full control. See Web Endpoints → Paging.
Add, Modify, Save, Remove) do NOT automatically persist changesSaveChanges() to commit all changes to the databaseSaveChanges() the EF change tracker is cleared — all entities saved in that call are now detached. To update one later, pass it through Modify() or Save() again before the next SaveChanges(). A failed SaveChanges() leaves every entry tracked (stock EF Core semantics), so you can fix or remove the offending entity and retry the same callEntityConstraintException — catch that, not DbUpdateException, around direct SaveChanges() calls (seeding, jobs). Transient faults (deadlocks, timeouts) are not wrapped and still throw DbUpdateException subtypes. See Built-in Features → Constraint ExceptionsEntityConcurrencyException; catch that, not DbUpdateConcurrencyException. See Built-in Features → Concurrency ExceptionsDetails(id) can go straight back into Modify() with its navigations loaded. When a foreign key — on the entity or on a Related() child — was set to another key, Modify() drops the reference navigation still pointing at the stored principal and removes the row from that principal’s loaded collections (it may still be in the graph through another path), so the new key is saved rather than overwritten by EF’s attach fixup; the entity then carries that navigation as null. A Related() child stays with the parent whose collection lists it, whatever its own parent key says; move it through the collections. To clear a relation, set both the foreign key and the navigation to null: an empty key beside a loaded navigation is left to the navigation, which is what a request that sends only the nested object relies onTask Save(TEntity item, CancellationToken token = default) // calls Add() or Modify() internally
Task Add(TEntity item, CancellationToken token = default)
Task<TEntity?> Modify(TEntity item, CancellationToken token = default)
Task Remove(TEntity item, CancellationToken token = default)
// Persist all changes to database
Task<int> SaveChanges(CancellationToken token = default)
Query builders are used to filter, sort entities and include navigation properties.
TSearchObject.Filter((query, so) => ...)SearchObject<TKey> is provided// interface
public interface IFilteredQueryBuilder<TEntity, TKey, in TSearchObject>
where TSearchObject : ISearchObject<TKey>
{
IQueryable<TEntity> Build(IQueryable<TEntity> query, TSearchObject? so);
}
// base class
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);
}
TSearchObject for the Entity who’s Filter is being executedSearchObject<TKey> is provided// interface
public interface IGlobalFilteredQueryBuilder
{
IQueryable<TEntity> Build<TEntity, TKey>(IQueryable<TEntity> query, ISearchObject<TKey>? so);
}
public interface IGlobalFilteredQueryBuilder<TEntity, TKey> : IGlobalFilteredQueryBuilder
{
IQueryable<TEntity> Build(IQueryable<TEntity> query, ISearchObject<TKey>? so);
}
// base class
public abstract class GlobalFilteredQueryBuilderBase<TEntity> : GlobalFilteredQueryBuilderBase<TEntity, int>;
public abstract class GlobalFilteredQueryBuilderBase<TEntity, TKey> : FilteredQueryBuilderBase<TEntity, TKey, ISearchObject<TKey>>,
IGlobalFilteredQueryBuilder<TEntity, TKey>
{
IQueryable<TEntity> IGlobalFilteredQueryBuilder<TEntity, TKey>.Build(IQueryable<TEntity> query, ISearchObject<TKey>? so)
=> Build(query, so);
IQueryable<T> IGlobalFilteredQueryBuilder.Build<T, TK>(IQueryable<T> query, ISearchObject<TK>? so)
// a search object of a foreign key type coerces to null — the filter then applies its
// key-agnostic default (e.g. hide archived rows); it must NOT step aside, or a
// soft-delete/security default would be silently dropped
=> Build(query.Cast<TEntity>(), so as ISearchObject<TKey>).Cast<T>();
}
A search object of a foreign key type coerces to null, so a keyed filter falls back to its key-agnostic
default (e.g. a security filter’s scoping predicate) rather than being dropped. The query builder runs one
variant per filter family, preferring the one whose key type matches the search object; when an entity uses
a non-int key, register the matching variants with AddDefaultGlobalQueryFilters<TKey>() so its typed fields
(Id/Ids) are honoured too. Key-agnostic defaults apply even when only the int variant is registered.
TSortyByEntitySortBy is provided// interface
public interface ISortedQueryBuilder<TEntity, TKey, TSortBy>
where TEntity : IEntity<TKey>
where TSortBy : struct, Enum
{
IQueryable<TEntity> SortBy(IQueryable<TEntity> query, TSortBy? sortBy = null);
}
TIncludesEntityIncludes is provided// interface
public interface IIncludableQueryBuilder<TEntity, TKey, TIncludes>
where TEntity : IEntity<TKey>
where TIncludes : struct, Enum
{
IQueryable<TEntity> AddIncludes(IQueryable<TEntity> query, TIncludes? includes = null);
}
Fill [NotMapped] properties here.
// interface
public interface IEntityProcessor<TEntity, TIncludes>
where TIncludes : struct, Enum
{
Task Process(IList<TEntity> items, TIncludes? includes, CancellationToken token = default);
}
Prepare child collections here, or calculated fields.
// interface
public interface IEntityPrepper<in TEntity> : IEntityPrepper
{
Task Prepare(TEntity modified, TEntity? original, CancellationToken token = default);
}
// base class
public abstract class EntityPrepperBase<TEntity> : IEntityPrepper<TEntity>
{
public abstract Task Prepare(TEntity modified, TEntity? original, CancellationToken token = default);
}
[ServerOwned] on a scalar (or e.ServerOwned(x => x.Code, mint) for the fluent form, which also mints on
create) restores that property from the stored row on update, so a PUT/PATCH that omits it cannot null it.
Enforced by AutoServerOwnedPrepper, registered by UseDefaults(). Being a prepper, it guards the
IEntityService write path only and leaves a workflow service’s raw DbContext write alone. It has no
bypass, so a workflow action saving through IEntityService is restored too — guard such fields with your
own prepper and a scoped “trusted writer” flag instead. See
Built-in features.
e.Related() is the shortcut for synchronizing owned child collections. It registers a RelatedCollectionPrepper that diffs the incoming collection against the stored one before SaveChanges(), marking items as added, modified or removed.
One writer per save path. A collection synchronized with
Related()is owned by the parent. Adding a.For<>()/IEntityService<T>for the same child is allowed — the registrations don’t conflict, sinceRelated()registers only a save-time prepper for the parent — but it is safe only under one condition: the parent’s input DTO must leave the collectionnull.
nullon the parent DTO → the sync is a no-op. It short-circuits before diffing, so the child’s own service is the sole writer. This is the supported way to give an owned child its own read/PATCH endpoints.- Collection present → the parent wins. Its next save re-diffs the collection and silently reverts rows written through the standalone service. Watch the difference between absent and empty:
nulltouches nothing,[]deletes every row — including when the navigation was never eager-loaded, since the prepper loads the rows from the store to diff against.Key-type caveat: deletions only happen when every incoming item has a non-null
Id. Withint/Guidkeys that always holds; with astringkey, one new child carrying a nullIdsuppresses all deletions in that save.Startup validation warns on the pairing because it cannot inspect your DTO shape — see §Validation. If the parent genuinely must send the collection, pick one authority: drop the
.For<>(), or drop theRelated()and load the navigation withInclude()in the query builder.
The signature is Related(navigationExpression, prepareFunc, configure), where both prepareFunc and configure are optional:
prepareFunc — a parent-level prepare callback, invoked with the parent entity.configure — a RelatedEntityBuilder callback for shaping the child collection. Use builder.Related(...) to synchronize a nested sub-collection (recursively, to any depth) and builder.Prepare(...) to run a per-item prepare on each child.// Sync the collection, with an optional parent-level prepare:
e.Related<TRelated, TRelatedKey>(x => x.Collection, parentEntity => { /* ... */ });
// Nest sub-collections or add a per-item prepare via the RelatedEntityBuilder:
e.Related<TRelated, TRelatedKey>(x => x.Collection, configure: builder =>
{
builder.Related(item => item.SubCollection); // sync a nested sub-collection
builder.Prepare(item => item.RecalculateTotals()); // per-item prepare on each TRelated
});
// Combine both — a parent-level prepare alongside the nested configuration:
e.Related<TRelated, TRelatedKey>(x => x.Collection,
parentEntity => { /* parent-level prepare */ },
builder =>
{
builder.Related(item => item.SubCollection);
builder.Prepare(item => item.RecalculateTotals());
});
SaveChangesInterceptors by DbContextSaveChanges() and SaveChangesAsync(). On the synchronous call a primer that awaits is waited on
without the caller’s synchronization context — its own awaits do not deadlock the caller, but it holds the calling thread for its I/O, so
prefer SaveChangesAsync() when primers do I/OUseEntities(e => e.UseDefaults());
without UseDefaults(), select it with e.WireDbContext(DbContextWiring.PrimerInterceptors)HasCreatedDbPrimer, HasLastModifiedDbPrimer) write UTC values by default; the
auto-wired UTC date convention (UseDefaults()) makes dates read from the database materialize as
DateTimeKind.Utc and serialize to JSON with the Z suffix; ConfigureDefaultJsonOptions() reads
request-body DateTime properties as UTC too, so a prepper sees both sides on one clock (standalone EF: .AddUtcDateTimeConvention() /
SetUtcDateTimeConvention() — Regira.DAL.EFcore.Extensions). Disable UTC handling with
UseEntities(e => e.UseUtc(false)) → local time, values used as given; the convention’s converter follows
the same policy (one process-wide decision: Regira.Utilities.DateTimeDefaults.UseUtc, on by default)// interface
public interface IEntityPrimer<in T>
{
Task PrepareAsync(T entity, EntityEntry entry, CancellationToken token = default);
bool CanPrepare(T entity);
}
// base class
public abstract class EntityPrimerBase<T> : IEntityPrimer<T>
{
public virtual async Task PrepareManyAsync(IList<EntityEntry> entries, CancellationToken token = default)
public abstract Task PrepareAsync(T entity, EntityEntry entry, CancellationToken token = default);
public virtual bool CanPrepare(T? entity) => entity != null;
}
Commit() for the saves inside an explicit
BeginTransaction() — also for contexts sharing that transaction through UseTransaction, whichever of them
commits it — and when an ambient TransactionScope completes. A failed save, a rollback, or a transaction
disposed without committing reacts to nothingDbTransaction itself — its reactions never run. A transaction begun outside EF and
handed to UseTransaction is known to have ended only when it commits or rolls back through EF: on Npgsql, which
reuses the transaction object of a pooled connection, one disposed without either leaves its reactions to the
next such transaction on that connectionIEntityChange<TEntity>: Kind (Added/Modified/Deleted — a soft delete is Modified),
Entity (the committed row, generated keys filled in), Original (the row as stored before the save) and
ChangedProperties, with the HasChanged(x => x.Status) and ChangedTo(x => x.Status, value) helpers. Values
are detached snapshots without navigationsModify or the Related() sync.
Every other write (Update() of a detached entity, a stub Attach or Remove, a delete through the service)
has its rows read during the save, one query per entity type — only for entity types a reactor is registered for.
ExecuteUpdate / ExecuteDelete bypass the change tracker, so no reactor sees themSaveChanges() returns, in registration order, in a DI scope of their own with a fresh
DbContext — a reactor that writes saves its own unit of work, and that save runs the reactors of what it wrote
(up to 8 levels deep). Hand slow work to a job systemUseEntities(e => e.UseDefaults()); without UseDefaults(), add
DbContextWiring.Reactors to e.WireDbContext(...)options.AddReactor<T>() — a reactor on an interface or base type reaches every entity it covers) or per entity (e.AddReactor<T>() — that entity only, whatever type the reactor is written against)services.UseEntities<MyDbContext>(e => e.UseDefaults())
.For<Order>(e =>
{
// inline: the second argument is the reaction's own scoped service provider
e.React(x => x.Status, OrderStatus.Shipped, (change, services, token) =>
services.GetRequiredService<IOrderMailer>().SendShipped(change.Entity.Id, token));
// class-based
e.AddReactor<OrderInvoicingReactor>();
});
public class OrderInvoicingReactor(IInvoiceService invoices) : EntityReactorBase<Order>
{
public override bool CanReact(IEntityChange<Order> change) => change.ChangedTo(x => x.Status, OrderStatus.Delivered);
public override Task React(IEntityChange<Order> change, CancellationToken token = default)
=> invoices.CreateFor(change.Entity.Id, token);
}
This example demonstrates how to configure entities with all helper services:
// Configure DbContext — only the provider; UseEntities(e => e.UseDefaults()) wires the interceptors
services.AddDbContext<MyDbContext>(db =>
{
db.UseSqlServer(connectionString);
});
// Configure Entity Services with all helper services
services
.UseEntities<MyDbContext>(options =>
{
// Global helper services (apply to all entities implementing an interface)
options.AddGlobalFilterQueryBuilder<FilterIdsQueryBuilder<int>>();
options.AddGlobalFilterQueryBuilder<FilterArchivablesQueryBuilder>();
// using Prepper shortcut (inline implementation)
options.AddPrepper<IHasAggregateKey>(x => x.AggregateKey ??= Guid.NewGuid());
options.AddPrimer<AutoTruncatePrimer>();
})
// Category
.For<Category, Guid>(e =>
{
// Query Filter
e.AddFilter<CategoryQueryFilter>();
// Sorting — a simple entity takes one fixed order; the request's ?sortBy= is honored only on complex entities
e.SortBy(query => query.OrderBy(x => x.Name));
// Processor
e.AddProcessor<CategoryProcessor>();
})
// Product
.For<Product, ProductSearchObject, ProductSortBy, ProductIncludes>(e =>
{
// Query Filter (inline)
e.Filter((query, so) =>
{
// filtering on Id is implemented by global filter
if (so?.MinPrice != null)
query = query.Where(x => x.Price >= so.MinPrice);
if (so?.MaxPrice != null)
query = query.Where(x => x.Price <= so.MaxPrice);
return query;
});
// Sorting
e.SortBy((query, sortBy) =>
{
return sortBy switch
{
ProductSortBy.Name => query.OrderBy(x => x.Name),
ProductSortBy.NameDesc => query.OrderByDescending(x => x.Name),
ProductSortBy.Price => query.OrderBy(x => x.Price),
ProductSortBy.PriceDesc => query.OrderByDescending(x => x.Price),
_ => query.OrderBy(x => x.Id)
};
});
// Include — one registration per entity (a second call replaces the first).
// Order inside an include when the relation carries sorted rows. Archived rows need no
// predicate here on net10.0: the archived filter is an EF query filter, so it also
// applies inside Include(...) — see Built-in features > Soft delete for the net8.0 gap.
e.Includes((query, includes) =>
{
if (includes?.HasFlag(ProductIncludes.Category) == true)
query = query.Include(x => x.Category);
if (includes?.HasFlag(ProductIncludes.Reviews) == true)
query = query.Include(x => x.Reviews!.OrderBy(r => r.SortOrder));
return query;
});
// Processor
e.Process((items, includes) =>
{
foreach (var item in items)
{
// Calculate display properties
item.DisplayPrice = $"${item.Price:F2}";
}
return Task.CompletedTask;
});
// Prepper
e.Prepare(item =>
{
// Ensure SKU is set
item.Sku ??= GenerateSku(item);
});
// Primer
e.AddPrimer<ProductPrimer>();
// Related entities — simple: just sync the collection
e.Related(x => x.Reviews);
})
// Order
.For<Order, int, OrderSearchObject, OrderSortBy, OrderIncludes>(e =>
{
e.AddFilter<OrderQueryFilter>();
// OrderOrThenBy / OrderOrThenByDescending (Regira.Entities.EFcore.Extensions) start the
// ordering or continue it with ThenBy — the lambda is called once per requested sort value
e.SortBy((query, sortBy) => sortBy switch
{
OrderSortBy.OrderNumber => query.OrderOrThenBy(x => x.OrderNumber),
OrderSortBy.OrderDate => query.OrderOrThenBy(x => x.OrderDate),
OrderSortBy.TotalAmount => query.OrderOrThenBy(x => x.TotalAmount),
_ => query.OrderOrThenByDescending(x => x.OrderDate)
});
e.AddIncludes<OrderIncludableQueryBuilder>();
e.AddProcessor<OrderProcessor>();
// Complex prepper with DbContext
e.Prepare(async (item, dbContext) =>
{
// Recalculate order totals
item.TotalAmount = item.OrderItems?.Sum(x => x.Quantity * x.UnitPrice) ?? 0;
await Task.CompletedTask;
});
// Simple related with parent-level prepare
e.Related(x => x.OrderItems, item => item.OrderItems?.SetSortOrder());
// Configure overload — nest sub-collections or add per-item prepare
// e.Related(x => x.OrderItems, builder =>
// {
// builder.Related(oi => oi.Options);
// builder.Prepare(oi => oi.RecalculateTotals());
// });
});
Registration Order Matters
EntityServiceCollectionOptions)Tip:
// Use extension methods to configure Entities.
// Take the interface as the 'this' parameter; return the concrete EntityServiceCollection<TContext>
// (every For<>() returns it, and only it implements IServiceCollection — chains stay composable).
public static class ProductServiceCollectionExtensions
{
public static EntityServiceCollection<TContext> AddProducts<TContext>(this IEntityServiceCollection<TContext> services)
where TContext : DbContext
=> services.For<Product>(e =>
{
// put logic here ...
});
}
// Resulting:
services
.UseEntities<MyDbContext>(/* ... */)
.AddProducts()
.AddCategories()
.AddOrders();