TCIS.Pluggable.Persistence.EntityFrameworkCore 1.0.0-rc.19

This is a prerelease version of TCIS.Pluggable.Persistence.EntityFrameworkCore.
dotnet add package TCIS.Pluggable.Persistence.EntityFrameworkCore --version 1.0.0-rc.19
                    
NuGet\Install-Package TCIS.Pluggable.Persistence.EntityFrameworkCore -Version 1.0.0-rc.19
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="TCIS.Pluggable.Persistence.EntityFrameworkCore" Version="1.0.0-rc.19" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="TCIS.Pluggable.Persistence.EntityFrameworkCore" Version="1.0.0-rc.19" />
                    
Directory.Packages.props
<PackageReference Include="TCIS.Pluggable.Persistence.EntityFrameworkCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add TCIS.Pluggable.Persistence.EntityFrameworkCore --version 1.0.0-rc.19
                    
#r "nuget: TCIS.Pluggable.Persistence.EntityFrameworkCore, 1.0.0-rc.19"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package TCIS.Pluggable.Persistence.EntityFrameworkCore@1.0.0-rc.19
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=TCIS.Pluggable.Persistence.EntityFrameworkCore&version=1.0.0-rc.19&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=TCIS.Pluggable.Persistence.EntityFrameworkCore&version=1.0.0-rc.19&prerelease
                    
Install as a Cake Tool

TCIS.Pluggable.Persistence.EntityFrameworkCore

The multi-tenant data layer of the Pluggable architecture: a tenant-aware DbContext, per-site EF model composition, connection-level isolation, and automatic audit stamping.

Do not register a DbContext with this package directly. Use a provider package — SqlServer, PostgreSql or Oracle.


Table of contents

Section Contents
1 The three isolation tiers
2 Registration
3 TBaseDbContext<TContext>
4 The global query filter is fail-closed
5 Reading soft-deleted rows
6 Cross-tenant reporting
7 Audit and TenantId stamping
8 Per-site entities
9 ⚠️ The EF model cache
10 Connection lifetime
11 Transactions
12 Pitfalls

1. The three isolation tiers

A tenant's tier comes from TenantInfo.IsolationLevel in the Tenant Store and decides everything below it.

Tier Isolation Application-level filter Database-level defence
Tier 1 — Row level One shared schema, a TenantId column ✅ EF global query filter Row-Level Security — mandatory
Tier 2 — Schema One schema per tenant ❌ not needed search_path / CURRENT_SCHEMA
Tier 3 — Database One database per tenant ❌ not needed A dedicated connection string

Connection strings come from the Tenant Store per request, never from appsettings. That is what allows one process to serve tenants that live on different servers.


2. Registration

builder.Services.AddPluggableSqlServer<TosDbContext>(commandTimeoutSeconds: 30);

The provider call registers the connection provider (scoped), calls AddPluggableDbContext with the command timeout, and registers IDbConnectionFactory for the Dapper read path.

What you get in DI

Service Notes
TosDbContext and DbContext The same instance
IUnitOfWork EfUnitOfWork
IGenericRepository<> GenericRepository<>
IDapperContext Reads through Dapper

IModelCacheKeyFactory is replaced by SiteModelCacheKeyFactory — see section 9.


3. TBaseDbContext<TContext>

Derive your context from it to inherit tenant isolation, per-site model composition and audit stamping:

public sealed class TosDbContext(DbContextOptions<TosDbContext> options, IWorkContextAccessor accessor)
    : TBaseDbContext<TosDbContext>(options, accessor)
{
    public DbSet<GateTransaction> GateTransactions => Set<GateTransaction>();
}

OnModelCreating composes the model in this order:

  1. Load every Platform module (SiteCode = DEFAULT)
  2. Load the plugin modules of the current site
  3. Scan entities from each module's assembly
  4. Call ISiteCoreModule.ConfigureDatabase on modules that implement it
  5. Apply the global query filter (tenant + soft delete)

4. The global query filter is fail-closed

WorkContext state Tenant filter Result
Present, Tier1_RowLevel ✅ applied Only the current tenant's rows
Present, Tier2_Schema / Tier3_Database ❌ not applied Already isolated by schema or database
Absent (Hangfire job, EventBus consumer, migration tool) applied CurrentTenantId is null → the query returns nothing

The last row is deliberate. A background job running without tenant context and sweeping across every tenant is a data leak, not a convenience.

If a job legitimately needs data, give it a tenant context — Hangfire and EventBus both restore WorkContext through context propagation.


5. Reading soft-deleted rows

EF Core keeps one query filter per entity, so the tenant predicate and the soft-delete predicate must live in the same expression. The consequence: IgnoreQueryFilters() removes both at once.

// ❌ WRONG — the intent was "show the recycle bin", the effect is "show EVERY tenant's orders"
var deleted = await db.Orders.IgnoreQueryFilters().Where(o => o.IsDeleted).ToListAsync();

// ✅ RIGHT — see deleted rows, tenant filter still intact
using (db.IncludeSoftDeleted())
{
    var deleted = await db.Orders.Where(o => o.IsDeleted).ToListAsync();
}

The scope is reference-counted and nests safely; it closes when the using block ends. If a whole DbContext exists for archive/restore work, override AllowSoftDeletedQuery => true instead of opening the scope at each call site.

On Tier 1, Row-Level Security still blocks a leak caused by IgnoreQueryFilters(). On Tier 3 nothing does — a perfectly innocent-looking query reads across tenants. That is why this is a rule, not a preference.


6. Cross-tenant reporting

When a query legitimately spans tenants, say so explicitly:

public sealed class ReportingDbContext(DbContextOptions<ReportingDbContext> options, IWorkContextAccessor accessor)
    : TBaseDbContext<ReportingDbContext>(options, accessor)
{
    // Group-wide reporting / sync jobs — bypass the tenant filter CONSCIOUSLY
    protected override bool AllowCrossTenantQuery => true;
}

AllowCrossTenantQuery is evaluated at query time, not baked into the model cache, so it can point at a flag you control rather than a constant.

Opening this flag turns off the application-level tenant defence. On Tier 1 the remaining line of defence is RLS in the database — make sure the DBA has enabled it.


7. Audit and TenantId stamping

TBaseDbContext overrides SaveChanges(bool) and SaveChangesAsync(bool, CancellationToken) — the two canonical EF Core extension points. All four entry points (SaveChanges(), SaveChanges(bool), SaveChangesAsync(ct), SaveChangesAsync(bool, ct)) therefore get audit stamps and an automatic TenantId.

Two audit contracts exist, serving existing products and new products side by side:

Interface Intended for CreatedBy Timestamps
IAuditEntity Existing products — matches legacy column types int? DateTime
IAuditableEntity New products string? DateTimeOffset

Pick exactly one per entity, according to the product it belongs to.

Timestamps come from a TimeProvider, so tests can freeze the clock instead of asserting on a range.


8. Per-site entities

A site that needs its own tables implements ISiteCoreModule:

public sealed class CatLaiModule : ISiteCoreModule
{
    public string SiteCode => "CATLAI";

    public void RegisterServices(IServiceCollection services, IConfiguration configuration) { }

    public void ConfigureDatabase(ModelBuilder builder)
        => builder.ApplyConfigurationsFromAssembly(GetType().Assembly);
}

A plugin that does not change the database schema should not implement ISiteCoreModule — leaving it out lets the site reuse the core EF model cache entry and saves memory.

Tier constraints on schema customisation — not enforced automatically:

Tier Schema customisation
Tier 1 — shared DB + RLS 🔴 Shared schema. Only nullable columns may be added, and the column is added for every port on that database
Tier 2 — own schema 🟢 free
Tier 3 — own database 🟢 free

On Tier 1, a NOT NULL column or a change to a Core column breaks the other ports.


9. ⚠️ The EF model cache

SiteModelCacheKeyFactory builds the key from (DbContext type, sorted module set, designTime, tier).

The key does not contain the site code — only the module set. Two sites loading the same modules share one model. That is deliberate, and it imposes two invariants:

# Invariant What breaks otherwise
1 A Platform module's ConfigureDatabase must produce identical mapping on every call Platform modules load for every site and contribute the same key fragment — non-deterministic mapping leaks across sites
2 TBaseDbContext.OnModelCreating and SiteModelCacheKeyFactory must both use TenantFilterPolicy EF Core serves the wrong model

Never reach for static or ambient state inside ConfigureDatabase: module instances are created once and kept like singletons, so anything read there sticks to every site.

Plugin modules are free to vary — their presence is part of the key.

Tier 2 in particular: the schema name belongs to the connection, not the model. Moving it into the model via HasDefaultSchema(siteCode) would require adding the site code to the cache key; without that it leaks across tenants.


10. Connection lifetime

Path Provider Behaviour
Write (EF Core) TenantDbConnectionProviderBase One connection cached for the whole scope, so session context / search_path is set once
Read (Dapper) TenantDbConnectionFactoryBase A new connection per call; the caller disposes it

Because the two paths use separate sessions, Dapper reads run in parallel with EF writes without competing for a connection. The flip side is that Dapper does not join an EF transaction — see section 11.

GetConnection() is guarded by a lock and throws ObjectDisposedException once the scope has been disposed. Both matter under load: the previous lock-free version could build two connections when several threads asked at once, and the second one was never disposed — a connection leak that only surfaces later, somewhere else, as "Timeout expired… prior to obtaining a connection from the pool".


11. Transactions

Explicit transactions work through the tenant-aware connection:

await uow.BeginTransactionAsync(ct);
try
{
    // … several SaveChanges calls …
    await uow.CommitTransactionAsync(ct);
}
catch
{
    await uow.RollbackTransactionAsync(ct);
    throw;
}

Most of the time you do not need one — SaveChangesAsync is already atomic. Reach for an explicit transaction only when several SaveChanges calls must succeed or fail together. Full guidance: TCIS.Persistence.EntityFrameworkCore.

For a Pluggable pipeline, open the transaction around the whole pipeline at the call site; steps keep calling SaveChangesAsync as usual. Transactions do not nest — a second BeginTransactionAsync throws, which forces ownership to be explicit.

Raw SQL inside a transaction must borrow EF's connection, because IDapperContext opens its own:

var conn = db.Database.GetDbConnection();
var tx   = db.Database.CurrentTransaction?.GetDbTransaction();
await conn.ExecuteAsync(sql, param, transaction: tx);

12. Pitfalls

# Pitfall Consequence
1 IgnoreQueryFilters() Removes the tenant filter as well — on Tier 3 nothing catches it
2 Expecting a background job to see data without a tenant context Fail-closed by design: the query returns nothing
3 Adding a NOT NULL column on Tier 1 Breaks every other port sharing that database
4 Static or ambient state in ConfigureDatabase Sticks to every site through the singleton module instance
5 Moving the Tier 2 schema name into the model Cross-tenant leak unless the site code joins the cache key
6 Implementing ISiteCoreModule in a plugin that changes no schema Extra model cache entries, wasted memory
7 Querying through IDapperContext on Tier 1 without a tenant predicate Only RLS stands between you and a cross-tenant read
8 Registering IModuleRegistry as scoped Captive state inside the EF model cache
Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (3)

Showing the top 3 NuGet packages that depend on TCIS.Pluggable.Persistence.EntityFrameworkCore:

Package Downloads
TCIS.Pluggable.Persistence.SqlServer

TCIS Core Framework is an application framework for building modular, multi-tenant applications on ASP.NET Core. Pluggable Persistence SqlServer

TCIS.Pluggable.Persistence.Oracle

TCIS Core Framework is an application framework for building modular, multi-tenant applications on ASP.NET Core. Pluggable Persistence Oracle

TCIS.Pluggable.Persistence.PostgreSql

TCIS Core Framework is an application framework for building modular, multi-tenant applications on ASP.NET Core. Pluggable Persistence PostgreSQL

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0-rc.19 9 8/13/2026
1.0.0-rc.18 6 8/13/2026
1.0.0-rc.17 26 8/13/2026
1.0.0-rc.16 36 8/13/2026
1.0.0-rc.15 41 8/12/2026
1.0.0-rc.14 48 8/12/2026
1.0.0-rc.13 46 8/11/2026
1.0.0-rc.12 57 8/10/2026
1.0.0-rc.11 74 7/28/2026
1.0.0-rc.10 69 7/24/2026
1.0.0-rc.9 69 7/21/2026
1.0.0-rc.8 65 7/21/2026
1.0.0-rc.7 58 7/17/2026
1.0.0-rc.6 64 7/7/2026
1.0.0-rc.5 71 7/7/2026