Table of Contents

The contracts

Everything in eQuantic.Core.Data hangs off a small set of interfaces in eQuantic.Core.Data.Repository. The persistence engine is deliberately kept out of the type signatures: it is IRepository<TEntity, TKey>, not IRepository<TUnitOfWork, TEntity, TKey> — application code compiled against the contracts does not know (or care) which store implements them.

The repository family

Interface What it adds
IReadRepository<TEntity, TKey> / IAsyncReadRepository<…> Point reads (Get), filtered reads (GetFiltered, AllMatching), single/first, paging (GetPaged), Count/Any/All
IRepository<TEntity, TKey> / IAsyncRepository<…> Writes: Add, Modify, Merge, Remove, plus set-based UpdateMany/DeleteMany
IQueryableRepository<…> / IAsyncQueryableRepository<…> The same, constrained to a queryable unit of work
IAggregateReadRepository<TEntity> Sum, Min, Max, Average — pushed down where the store can
IGroupedReadRepository<TEntity> Typed GroupByAsync with aggregates and HAVING
IContinuationReadRepository<TEntity> GetPageAsync(pageSize, continuationToken) — keyset/native continuation paging
IStreamingReadRepository<TEntity> GetStreamAsyncIAsyncEnumerable<TEntity>, one page in memory at a time
IExplainableRepository<TEntity> Explain(options) — the query plan, before you run it
IUnionQueryRunner UnionAsync — typed UNION / UNION ALL across entities

A repository instance implements all of these that its provider supports; cast to the capability you need ((IAggregateReadRepository<Product>)repo) or resolve the specific interface from DI.

You rarely write a repository class. The DI extensions register open generics, so IAsyncRepository<Product, Guid> resolves for any entity in the model. Subclass the provider's repository only to add domain-specific query methods.

The unit of work

Interface What it is
IUnitOfWork Commit/CommitAsync, RollbackChanges, repository factories
IQueryableUnitOfWork The provider-backed unit of work repositories are built over

Writes stage on the unit of work and flush on Commit — see The lean write model. One unit of work per scope (per request in a web app) is the intended lifetime, and the DI extensions register exactly that.

The query surface: QueryOptions<TEntity>

Every read takes an optional QueryOptions<TEntity> — a small, serializable description of the query around the method's own arguments:

var options = new QueryOptions<Product>()
    .Where(p => p.Category == "Peripherals")           // typed lambda…
    .Where(p => p.Price, FilterOperator.LessThan, 30m) // …typed member + operator…
    .Where("price", FilterOperator.LessThan, 30)       // …path + operator (wire-friendly)…
    .Where(specification)                              // …or a specification
    .OrderBy(p => p.Price).ThenByDescending(p => p.Name)
    .Include(nameof(Product.Supplier))           // navigations (relational, MongoDB)
    .IgnoringQueryFilters()                      // opt out of global filters
    .WithTag("catalog-page");                    // lands in traces / query text

Provider packages add store-specific opt-ins as extension methods — .AllowFiltering() / .AllowClientEvaluation() / .WithConsistency(…) on Cassandra, for example. They compose on the same options object, so the shape of a query stays portable and the store-specific costs stay visible at the call site.

Paging types are equally small: PageRequest(pageIndex, pageSize) in, PagedResult<T> (items + total count) out; ContinuedResult<T> (items + opaque continuation token) for token paging.

Global query filters

QueryFilters registers per-entity predicates that AND themselves into every read — the mechanism behind multi-tenancy and soft-delete filtering:

services.AddSingleton(new QueryFilters()
    .For<Order>(scope =>
        scope.GetService(typeof(ITenantAccessor)) is ITenantAccessor tenant
            ? order => order.TenantId == tenant.Id
            : null));

The factory overload runs per scope, so the filter can read the current request. Opt out per query with .IgnoringQueryFilters(). Soft-delete entities get their live-rows filter automatically — see Lifecycle.

Entities

An entity implements IEntity<TKey> (GetKey/SetKey). No base class, no tracking proxies, no required parameterless-constructor gymnastics. The interface lives in eQuantic.Core.DataModel (and is type-forwarded from this package), so entities can be shared with the wider eQuantic stack — EntityDataBase, EntityHistoryDataBase and friends all satisfy it.