Table of Contents

Bsft.Data

Server-side filtering, sorting and pagination driven by a declared whitelist, plus multi-tenancy that is enforced by the query pipeline rather than by each handler remembering to add a WHERE.

Full-text search lives in the same module but has its own page: search.md.

There is no repository or unit-of-work abstraction in this framework. Handlers work against IQueryable<T> from a DbContext directly. Bsft.Data supplies the query surface, not a persistence indirection.

Declaring a query surface

A query configuration derives from QueryConfigurationBase<TEntity, TDto> and declares which DTO fields are filterable and sortable:

public class ProductQueryConfiguration : QueryConfigurationBase<Product, ProductDto>
{
    public override void Configure(QueryConfigurationBuilder<Product, ProductDto> builder)
    {
        builder.Field(d => d.Name)
            .AllowFilter(FilterOperator.Eq, FilterOperator.Contains, FilterOperator.StartsWith)
            .AllowSort();

        builder.Field(d => d.Price)
            .AllowFilter(FilterOperator.Gt, FilterOperator.Lt, FilterOperator.Gte, FilterOperator.Lte)
            .AllowSort();

        builder.MaxPageSize(50);
        builder.DefaultSort(d => d.Name, SortDirection.Asc);
    }
}

A field not declared here cannot be filtered or sorted at all. That is the point: the client sends field names, and an undeclared name is rejected rather than compiled into an expression. MapsTo overrides the entity property when the DTO name differs; without it the mapping is by name, case-insensitively.

MaxPageSize defaults to 100. There is no default sort field unless you set one, and a field named in DefaultSort has to be declared with AllowSort() like any other. A host that runs startup validation — anything on AddBsftDefaults — refuses to start otherwise: the default sort orders every request that states no sort of its own, so an undeclared field there breaks the most ordinary request there is, and it is knowable long before that. This is new in 1.3.1, see the 1.3.1 migration notes.

Setup

services.AddBsftDataQueryFiltering(qf => qf
    .FromAssembly(typeof(ProductQueryConfiguration).Assembly));

The configure delegate is required. The scan registers, per discovered configuration, an IQueryExecutor<TEntity, TDto> and an ISearchExecutor<TEntity, TDto>, plus the data telemetry source and the search startup validator.

Both entry points live on Bsft.Data.DependencyInjection.DataServiceCollectionExtensions, the package's one registration class. The query-configuration types — QueryConfigurationBase<,>, QueryConfigurationBuilder<,>, FieldBuilder<,>, QueryFieldMap<>, IQueryExecutor<,> and QueryFilteringConfiguration — are in Bsft.Data.Filtering, beside the filter compiler they feed; they were in Bsft.Data.Configuration, which was not a folder these conventions name. IDataTelemetry is in Bsft.Data itself, because Diagnostics/ is a structural folder that adds no namespace segment.

The relational model warm-up

EF Core builds a model's relational model (tables, columns, mappings) lazily, on the first GetRelationalModel call, and that call has a check-then-act gap: it reads the built relational model, finds none, and only then reads the factory that builds it, while the caller that does build it stores the result and removes the factory in between. A caller caught between the two reads finds neither and throws "The model must be finalized and its runtime dependencies must be initialized before 'GetRelationalModel' can be used". The gap exists only while a model is new, so it hits concurrent first requests (or background workers) right after a host starts. It is present in EF Core 9 and 10.

AddBsftDataQueryFiltering, AddBsftDataUnitOfWork<TContext> and AddBsftDataMultiTenancy register a hosted service that closes it; a host using EF Core without any of them calls AddBsftDataModelWarmup() itself. Calling it more than once registers it once.

services.AddDbContext<AppDbContext>(o => o.UseNpgsql(connectionString));
services.AddBsftDataModelWarmup();

The service subscribes to EF Core's diagnostic listener in StartingAsync, which the host runs for every lifecycle service before it starts any hosted service, and builds the runtime model's relational model when a context reports ContextInitialized — before that context can run a command — under one process-wide lock. Every later caller finds the built model on its first read. It needs no knowledge of how a context is registered (AddDbContext, a factory, a pool or new), resolves nothing itself, skips non-relational providers, and costs one annotation lookup per context once the model is built. The subscription ends when the host has stopped.

Only the runtime model is built. The design-time model that migrations and EnsureCreated use is a second, separately cached model; building it for every context type would double the model cost of every host to cover a path that runs once, at deployment.

To keep EF Core's lazy build, turn it off:

services.Configure<BsftDataOptions>(o => o.ShouldWarmRelationalModels = false);

or set Bsft:Data:ShouldWarmRelationalModels to false where the section is bound (AddBsftDataMultiTenancy(configuration, ...)).

A test has no host to run the hosted service, so Bsft.Data.Testing.TestRelationalModelWarmup.EnsureRelationalModelBuilt(this DbContext) builds the same runtime model from a test's own startup hook. tests/Shared/RelationalModelWarmup.cs calls it for every relational EF test project in this repository and builds the design-time model itself, the one path this guard leaves lazy on purpose.

The unit of work

DbContextUnitOfWork<TContext> is the framework's implementation of both persistence contracts in Bsft.Core.Persistence over the application's own context:

services.AddDbContext<AppDbContext>(o => o.UseNpgsql(connectionString));
services.AddBsftDataUnitOfWork<AppDbContext>();

That registers one scoped instance behind IUnitOfWork and ITransactionalUnitOfWork. A commit is the context's SaveChangesAsync; a unit is one transaction on the context's connection, run through the provider's execution strategy so a host that configured retries gets them instead of an exception. A transaction the host already opened is joined rather than nested, because a second transaction on the same connection would either fail or quietly commit one half on its own. On a non-relational provider — the in-memory one a host meters with — the work simply runs, which is the same limitation the framework's in-memory stores state about themselves.

It is the host's context on purpose: every package here that persists through EF Core writes into the application's context rather than one of its own, so a row it stages commits with whatever else the request was writing. Registration is TryAdd throughout, so a host with a unit of work of its own — one spanning two stores, or something this framework never sees — registers it first and keeps it.

This is what a package typed against IUnitOfWork finds: the API key expiry reminder sweep (Bsft.Auth.ApiKeys) and the notification digest sweep (Bsft.Notifications) both close their own unit of work through it.

Requesting a page

public record PagedRequest(
    FilterNode? Filter = null,
    IReadOnlyList<SortItem>? Sort = null,
    int Page = 1,
    int PageSize = 25)
{
    public SearchRequest? Search { get; init; }
}

DefaultPageSize is 25. Search is deliberately not positional — set it with an object initialiser or with.

The executor applies filter, sort and paging:

public interface IQueryExecutor<TEntity, TDto>
{
    Task<PagedResult<TEntity>> ExecuteAsync(IQueryable<TEntity> query, PagedRequest request, CancellationToken ct = default);
}

and returns

public record PagedResult<T>(IReadOnlyList<T> Items, int TotalCount, int Page, int PageSize, bool HasNextPage);

For a query with no declared surface, the plain extension is enough:

var page = await db.Products.AsNoTracking().ToPagedResultAsync(page: 1, pageSize: 25, ct);

A list item of a resource that supports conditional writes carries the entity tag of its own row, so a client can write straight from a list row without reading the resource first. The contract is one nullable member:

public interface IEntityTaggedItem { string? ETag { get; } }

PagedResult<T> is unaffected — the validator belongs to the row, not to the page — and the member is nullable so a resource with no concurrency token publishes none. The tag must be the same value the resource's GET by id serves; the rules and the one-place derivation are in crud-conventions.md.

The filter language

Filters arrive as a small JSON AST rather than a string expression, so there is nothing to parse and nothing to inject:

public class FilterNode;                       // "comparison" | "present" | "and" | "or"
public class AndNode : FilterNode { public required List<FilterNode> And { get; set; } }
public class OrNode  : FilterNode { public required List<FilterNode> Or  { get; set; } }
public class ComparisonNode : FilterNode
{
    public required string Field { get; set; }
    public required FilterOperator Op { get; set; }
    public required JsonElement Value { get; set; }
}
public class PresenceNode : FilterNode         // the pr (present) operator — unary, no value
{
    public required string Field { get; set; }
}

FilterOperator covers Eq, Neq, Gt, Gte, Lt, Lte, Contains, NotContains, StartsWith, NotStartsWith, EndsWith, NotEndsWith, Present, serialised as eq, neq, gt, gte, lt, lte, contains, !contains, startsWith, !startsWith, endsWith, !endsWith, pr.

The vocabulary is audited against SCIM (RFC 7644 §3.4.2.2) — the only IETF RFC with a formal filter grammar — for its operator set and semantics (eq, ne, co, sw, ew, gt, ge, lt, le, pr, plus and/or); the wire format stays the JSON AST rather than SCIM's string expression. The full lineage (JSON:API's filter param, OData deliberately rejected, the JSON-AST family) and the per-operator mapping table live in crud-conventions.md.

Present (pr) is unary — it asks only whether a field has a value — so it is carried by its own PresenceNode rather than a ComparisonNode with a faked value, which keeps ComparisonNode.Value a required member. It compiles to col IS NOT NULL for a nullable or reference field and to a constant true for a non-nullable value field, since that value is always present. The string operators (contains, startsWith, endsWith and their negations) are accepted only against string-typed fields; aimed at a non-string field they are a 400, not a 500.

A null value is the absence of one rather than a value to compare against, so only equality says anything about it. {"op":"eq","value":null} compiles to col IS NULL and neq to col IS NOT NULL, on a scalar field: a nullable value field, a string or any other reference-typed one. Against a field that can never be null it is a 400, and so is a null carried by any other operator, because each of those would otherwise answer rather than refuse — co against nothing lowers to a search for the empty string and matches every row, an ordering operator lifts to a comparison that is false for all of them. A field mapped to a whole owned type rather than to one of its columns is outside this: the database reads such a comparison column by column while an in-memory evaluation reads it as a reference, and the two can disagree.

A node is compiled against the whitelist, which is where an undeclared field or a disallowed operator is refused:

Expression<Func<T, bool>> predicate = node.Compile(fieldMap);

Multi-tenancy

services.AddBsftDataMultiTenancy(mt => mt.ResolveFromHeader("X-Tenant-Id"));

MultiTenancyConfiguration offers ResolveFromHeader(string? = null), ResolveFromClaim(string? = null), ResolveFromSubdomain() and ResolveUsing<TResolver>(). It selects the resolver type; the header name and the claim type it reads live on BsftDataOptions.MultiTenancy, whose defaults are DataDefaults.TenantHeaderName ("X-Tenant-Id") and DataDefaults.TenantClaimType ("tenant_id"). Passing a name states it in code and it is applied after the configuration section, so it wins; passing none leaves whatever the section says:

services.AddBsftDataMultiTenancy(configuration, mt => mt.ResolveFromHeader());
// section "Bsft:Data"
{ "Bsft": { "Data": { "MultiTenancy": { "HeaderName": "X-Account" } } } }

The header name must not be blank and must be a valid HTTP field name — one a request cannot carry would leave the tenant silently unresolved — and the claim type must not be blank. Both are checked inside AddBsftDataMultiTenancy and again on start.

The resolver is a singleton and reads its name from IOptions<BsftDataOptions> once, at construction; the context is scoped:

public interface ITenantContext
{
    string? TenantId { get; }
    bool IsResolved { get; }
}

This lives in Bsft.Data.MultiTenancy. (A second ITenantContext once existed in a Bsft.Data.Tenancy namespace; it is gone — see the 1.3 migration notes.)

AddBsftDataMultiTenancy registers the context, the resolver and TenantSaveChangesInterceptor. It does not add the middleware, and there is no UseBsftMultiTenancy() — resolve the tenant per request explicitly:

app.UseMiddleware<TenantMiddleware>();

Entities opt in with ITenantEntity (string? TenantId { get; set; }), or by deriving from TenantEntity. Two mechanisms then apply:

  • UseTenantFilter(this) in OnModelCreating adds a global query filter, so reads are scoped without the handler asking.
  • TenantSaveChangesInterceptor stamps TenantId on inserts, and throws on an update that would move a row across tenants, on an insert with no tenant resolved, and — deny by default — on any update of a tenant-scoped entity from a scope with no tenant resolved.

The interceptor is registered for you, but putting it on the context is the host's job, and it is the one omission nothing at runtime reports: reads stay filtered while writes quietly go unstamped. A startup validator therefore fails the host when a context implementing ITenantScopedDbContext is built without it, whether it was named on the registration or added in the context's own OnConfiguring:

services.AddDbContext<AppDbContext>((sp, options) => options
    .UseSqlServer(connectionString)
    .AddInterceptors(sp.GetRequiredService<TenantSaveChangesInterceptor>()));

Registering the interceptor in the container instead, as an IInterceptor or an ISaveChangesInterceptor, does not work and the check does not accept it: EF Core reads interceptors off the context's options and does not resolve them from the application's container, so one registered only there never runs. The check finds a context through its DbContextOptions<TContext> registration, so one the host constructs itself is never examined. Options registered for the lifetime of the application, which is what AddDbContextFactory and an explicit optionsLifetime of Singleton do, are failed outright: a guard built into them is resolved from the root provider and keeps whatever tenant was current when it was built, so every later request would be stamped with the first one. A context that cannot be constructed at startup is passed with a warning rather than failed.

The one way past that last refusal is to say so at the call site. A background job that reads rows across tenants and writes each one back where it found it wraps its save in context.AllowTenantUnchangedWrites() (TenantGuardScope); inside that declaration an update whose own TenantId is unchanged from the value it was loaded with is allowed, and one that moves the row to another tenant is still refused:

using (context.AllowTenantUnchangedWrites())
{
    await context.SaveChangesAsync(ct);
}

The declaration is required because the entity's values cannot carry the intent. Comparing TenantId against its OriginalValue looks like a check but is not one on its own: for an entity that was never loaded through this context — DbSet.Update, or Attach plus EntityState.Modified — EF Core seeds the original values from the current ones, so the two are equal whatever tenant the caller put on the entity. Only make the declaration where the rows being written came from a tracked query on the same context.

public sealed class AppDbContext : DbContext, ITenantScopedDbContext
{
    private readonly ITenantContext _tenantContext;

    public string? CurrentTenantId => _tenantContext.TenantId;

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.UseTenantFilter(this);
    }
}

services.AddDbContext<AppDbContext>((sp, options) =>
    options.UseSqlServer(connectionString)
        .AddInterceptors(sp.GetRequiredService<TenantSaveChangesInterceptor>()));

Pass the context, not the tenant context, and the reason is not style. EF Core caches the model — and every query filter expression in it — per context type for the life of the process, while ITenantContext is scoped to a request. A filter built from the tenant context therefore holds the instance of whichever scope built the model first and keeps filtering on that tenant forever; a second tenant then reads nothing, or reads the first tenant's rows. A filter that reads a member of the context is rewritten by EF Core to the context executing the query, so the tenant is resolved per query. That is what ITenantScopedDbContext exists for, and the overload is constrained to where TContext : DbContext, ITenantScopedDbContext, since only the context itself gets rewritten — an implementation that is not the context would be captured exactly like the tenant value.

Register a tenant-scoped context with AddDbContext, not AddDbContextPool. A pooled context is constructed once and reused, so the ITenantContext its constructor received is never resolved again and CurrentTenantId freezes on the first tenant that rented it — the same defect as the captured filter, arriving through pooling instead of the model cache. AddBsftDataMultiTenancy registers a startup validator that refuses this combination by name, so the mistake fails the host at startup rather than in production. The check reads the registrations rather than the built provider, because pooling is only ever visible as a registration; it therefore holds whichever order AddBsftDataMultiTenancy and AddDbContextPool were written in, and it does not fire for a pooled context that is not tenant-scoped. A host that calls UseTenantFilter without registering multi-tenancy through this framework gets no such check, and the rule above is then the whole story.

UseStaticTenantFilter(tenantContext) is the same filter for a host whose tenant is fixed for the whole process — one deployment per tenant. It is a separate name rather than an overload on purpose: the two once differed only in the static type of one argument, so passing the ITenantContext field instead of the context compiled, started, served and leaked across tenants with nothing to signal it. Anywhere the tenant varies per request, which is what ResolveFromHeader and ResolveFromClaim are for, UseTenantFilter(this) is the only correct choice. This was a defect through 1.3.5; see the 1.3.6 migration notes.

Failing closed is deliberate. A missing tenant produces an exception, not a write that quietly lands in the wrong tenant or in none.

A sandbox tenant

A developer trying the platform out, or a payment provider's test mode, wants a tenant that is not a customer's. AddBsftSandboxTenants() registers ISandboxTenantProvisioner, which hands one out and takes it back:

var sandbox = await provisioner.ProvisionAsync(new SandboxTenantSpecification
{
    Label = "Trying the reports API",
    RequestedBy = developer.Id,
    Lifetime = TimeSpan.FromDays(2),     // or leave it unset for the configured default
});

scope.ServiceProvider.EnterTenant(sandbox.TenantId, "The sandbox request");

It is entered like any other tenant, with EnterTenant, because a sandbox entered through a mechanism of its own would exercise a path production never takes.

This is not a tenant lifecycle. TenantId is a plain string this framework has no table for, so there is no organization created, nothing seeded, no membership and no billing. What is on offer is an identifier nothing else was given, and a way to give it back.

Two things the contract states rather than leaves to prose. A sandbox tenant is isolated in exactly the sense every other tenant is — by the scoping your own queries apply. A query that forgets its tenant predicate has a leaky sandbox, and nothing here can fix that. And teardown removes what this seam knows about: the registry entry, plus whatever each registered ISandboxTenantCleanup speaks for. Rows you wrote under that tenant id through your own DbContext are yours to remove, and SandboxTeardown.ClearedBy names the handlers that actually ran so a caller can tell rather than assume.

Two seams, and the host brings both:

Seam Default Why
ISandboxTenantRegistry none, and startup fails without it The promise is an identifier nothing else uses, which only something durable keeps across a restart. An in-memory default would look like the seam working and hand out a colliding identifier the first time the process recycled.
ISandboxTenantCleanup none, and none is required Each implementation speaks for one package's rows. A host with no handler gets a teardown that forgets the registry entry and says it cleared nothing.

The registry is also what makes teardown safe: TearDownAsync runs handlers only over an identifier the registry knows, so naming a real tenant reaches nothing and answers WasKnown: false. Tearing the same sandbox down twice is therefore safe too. Handlers run before the registry entry goes, so a handler that throws leaves the sandbox listed and the teardown repeatable rather than leaving rows behind under an identifier nothing names any more.

ExpiresAt is a statement, not a mechanism: nothing here sweeps an expired sandbox. A host that wants them collected reads its own registry and calls the provisioner, which is a job rather than a seam.

Option (Bsft:SandboxTenants) Default Effect
IdPrefix sandbox What every sandbox identifier begins with. A readable marker, not a boundary: the registry is what prevents a collision.
DefaultLifetime 7 days How long a sandbox lasts when the caller named none.
MaxProvisionAttempts 5 How many identifiers to try before failing rather than spinning.

Key comparison

String keys compare ordinally — case-sensitive and culture-independent — and that is the contract, not an implementation detail. It matters because several packages ship a store as a pair: an in-memory implementation and an EF Core twin behind one interface. The in-memory halves all key ordinally (InMemoryJobStore on the job name, InMemorySagaStore and InMemorySagaTimeoutStore on the saga correlation key, InMemoryIdempotencyStore on the (Scope, Key) idempotency key), while the EF twins compare with LINQ predicates that EF Core translates to SQL, so the database column collation decides the comparison.

Under SQL Server's default case-insensitive collation the two disagree: two keys differing only in case are two entries in memory but one row in the database — a silent idempotency replay, two saga instances folded into one, or a unique-index violation when seeding a job. So that production matches the ordinal contract, give the affected key columns a case-sensitive or binary collation where case matters:

// idempotency Scope/Key, saga CorrelationKey (+ timeout SagaType/Name),
// scheduling job name
builder.Property(e => e.Key).UseCollation("SQL_Latin1_General_CP1_CS_AS");

PostgreSQL is case-sensitive by default, so no action is needed there. The full rationale, the rejected alternatives and the per-package list are recorded in ADR-0003.

The package split

The query surface ships as two packages, because the two halves have entirely different dependency needs:

Package Contains Depends on
Bsft.Data.Contracts PagedRequest, PagedResult<T>, SortItem, SortDirection, the filter AST (FilterNode, AndNode, OrNode, ComparisonNode, PresenceNode, FilterOperator), SearchRequest, SearchMode, RankedItem<T> nothing at all
Bsft.Data the query executor, the filter compiler, the search pipeline, multi-tenancy, the EF Core extensions EF Core, EF Core Relational, ASP.NET Core HTTP abstractions, plus the contracts

The split exists for the client side. A Blazor WebAssembly front end deserialises a PagedResult<T> and builds a PagedRequest; referencing Bsft.Data for those would pull EF Core and its relational provider into the browser bundle for types that are records with no behaviour. A console tool or an integration-test client is in the same position.

What did not move is as deliberate as what did. SearchScopeState, IOrganizationScopedEntity and ISearchScopeProvider have no dependencies either, but they are the server's visibility boundary rather than part of the wire contract: a client neither sends nor receives them, and putting them in a package a browser references would advertise them as something it is meant to fill in.

Nothing changed for an application that references Bsft.Data. The namespaces are unchanged — Bsft.Data.Pagination, Bsft.Data.Filtering and Bsft.Data.Search are what they were, and only the assembly behind them moved — and Bsft.Data carries a [assembly: TypeForwardedTo] for every moved type, so an assembly compiled against 1.3.2 keeps loading unchanged. No using needs touching and no rebuild is required.

Take the contracts alone when a project uses the query types but never executes a query. Bsft.Notifications.InApp is the framework's own case: its inbox contract returns PagedResult<InAppNotification> and needs nothing else from the data layer, so it references Bsft.Data.Contracts and leaves the EF Core work to Bsft.Notifications.InApp.EFCore.

Search provider packages

Bsft.Data declares what is searchable; an engine package supplies the ISearchProvider that executes it, plus the migration helpers that create the index. There are three — Bsft.Data.SqlServer, Bsft.Data.Sqlite and Bsft.Data.Postgres — and an application registers exactly one. Swapping engines does not touch a call site. See search.md.

Example

public sealed class GetProductsHandler(IInventoryDbContext db, IQueryExecutor<Product, ProductDto> executor)
    : IQueryHandler<GetProducts, PagedResult<Product>>
{
    public Task<PagedResult<Product>> HandleAsync(GetProducts query, CancellationToken ct = default) =>
        executor.ExecuteAsync(db.Products.AsNoTracking(), query.Request, ct);
}

See also

  • search.md — full-text search over the same declarations.
  • mapping.md — projecting the page to DTOs inside the query.
  • Bsft.Organization — org-unit scoping, the second scoping axis.