Multi-tenancy
Make a Module tenant-scoped: every command and query of it needs a tenant, every EF Core read sees only the Operation's tenant, and no save ever reaches another tenant's aggregate.
Multi-tenancy is not a package: it is a registration across packages your Host already has. The tenant is OperationContext.Tenant, which the HTTP boundary takes only from a verified claim. AddPouryaKitTenancy makes a Module tenant-scoped, so every command and query of it needs a tenant. ITenantScoped marks an aggregate as one tenant's: EF Core reads it only for the Operation's tenant, and the Unit of Work refuses any save that would change another tenant's. Deliveries into a tenant-scoped Module run under the tenant they carry, and one without a tenant is refused.
Plain SQL states its tenant itself
No EF Core filter reaches a Dapper read. In a tenant-scoped Module, every SQL read of a tenant-scoped table states the tenant the Kit's lease bound at the top level of its WHERE, and a structure test holds each such read to one shape (Reads).
When to use it
- One deployment serves several companies or customers, and none may ever see another's data.
- You declare a Module, or an application of one Module, tenant-scoped.
- You mark an aggregate as one tenant's and set its tenant from the Operation Context.
- A read must cross tenants on purpose, such as an operator's view.
- You diagnose
Tenant.Mismatch,Tenant.NotLoadedorTenant.Required, or test with two tenants.
How it works
Declare the Module tenant-scoped, first
Call AddPouryaKitTenancy() first in the Composition Root, so the tenant requirement refuses a tenantless Operation before any other authorizer of the Module reads data. It covers every command and query the Module can dispatch, a trusted continuation's included.
Mark each tenant's aggregate
Implement ITenantScoped on every aggregate that holds a tenant's data. The aggregate sets its own Tenant, from the tenant its handler takes from the Operation Context; the Kit never sets it.
Read and save as usual
EF Core reads go through a named query filter that returns only the Operation's tenant's rows, and the save checks every aggregate it changes. Set the filter aside only on purpose, by name, in a query over the DbContext.
In the Proving Sample
Catalog is tenant-scoped. Its product sets its own tenant when it is created:
public static Result<Product> Create(
ProductId id,
CategoryId categoryId,
string name,
string tenant
)
{
ArgumentNullException.ThrowIfNull(id);
ArgumentNullException.ThrowIfNull(categoryId);
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
if (string.IsNullOrWhiteSpace(name))
{
return ProductErrors.NameRequired;
}
var product = new Product
{
Id = id,
Tenant = tenant,
CategoryId = categoryId,
Name = name.Trim(),
};
product.Raise(new ProductCreatedDomainEvent(id, categoryId, product.Name));
return product;
}Its plain SQL read states the tenant the lease bound:
// Plain SQL reads no query filter, so it compares the tenant the lease bound itself, as it names is_deleted.
internal sealed class GetProductQueryHandler(IReadConnectionFactory connections)
: QueryHandler<GetProductQuery, ProductDto>
{
public override async Task<Result<ProductDto>> Handle(
GetProductQuery query,
CancellationToken cancellationToken
)
{
await using var connection = await connections.OpenAsync(cancellationToken);
var product = await connection.QuerySingleOrDefaultAsync<ProductDto>(
new CommandDefinition(
"select id, category_id as categoryid, name from catalog.products where id = @Id and tenant = current_setting('pouryakit.tenant') and is_deleted = false",
new { Id = new ProductId(query.ProductId) },
cancellationToken: cancellationToken
)
);
return product is null ? ProductErrors.NotFound : product;
}
}The Kit's tests prove the isolation at the Gateway with two tenants: the north tenant finds none of the south tenant's products, renames none and removes only its own.
[Fact]
public async Task A_tenant_reads_writes_and_removes_none_of_another_tenants_products_through_the_gateway()
{
var north = await CreateProductAsync("north", "Lamp");
var south = await CreateProductAsync("south", "Lamp");
var catalog = Fixture.Gateway<CatalogModule>();
var got = await catalog.QueryAsync(
new GetProductQuery(south),
For("north"),
TestContext.Current.CancellationToken
);
var listed = await catalog.QueryAsync(
new ListProductsQuery(new PageRequest()),
For("north"),
TestContext.Current.CancellationToken
);
var renamed = await catalog.SendAsync(
new RenameProductCommand(south, "Shade"),
For("north"),
TestContext.Current.CancellationToken
);
var removed = await catalog.SendAsync(
new RemoveProductsCommand(["Lamp"]),
For("north"),
TestContext.Current.CancellationToken
);
Assert.Equal(ProductErrors.NotFound, Assert.Single(got.Errors));
Assert.Equal([north], listed.Value.Items.Select(item => item.Id.Value));
Assert.Equal(ProductErrors.NotFound, Assert.Single(renamed.Errors));
Assert.Equal(1, removed.Value);
Assert.Equal(
[
["north", "Lamp", true],
["south", "Lamp", false],
],
await Fixture.RowsAsync(
"select tenant, name, is_deleted from catalog.products order by tenant"
)
);
}Guarantees and failures
| You want to know | Read |
|---|---|
| Everything tenancy guarantees, in one list | What the Kit guarantees |
| What the requirement covers, and the build failures of a tenant-scoped Module | Declaring a tenant-scoped Module |
| How the filter reads parts, and how a Dapper read holds itself to the tenant | Reads |
When a save fails with Tenant.Mismatch or Tenant.NotLoaded | Saves |
| What a delivery with no tenant does | Deliveries |
| What tenancy does not cover | Limits |
Next steps
Make a Module tenant-scoped
The declaration, the aggregates, the migration and the two-tenant test.
Operation Context, Actor and tenant
Where the tenant comes from, and how it travels with the work.
Security
The bearer scheme whose verified claim becomes the tenant.
Reference
Multi-tenancy is a registration across packages the Host already has, not a package: ITenantScoped (PouryaKit.Domain) marks an aggregate as one tenant's, PouryaKitDbContext and the Unit of Work (PouryaKit.Persistence.EFCore) read and save it only for the Operation's tenant, AddPouryaKitTenancy (PouryaKit.Cqrs) makes a Module, or an application without Modules, tenant-scoped, so that every command and query of it needs a tenant, and the delivery step (PouryaKit.Messaging.Wolverine) applies no delivery without one there. The tenant is OperationContext.Tenant, which the HTTP boundary takes only from a verified claim (security.md, web.md).
- Declaring a tenant-scoped Module
- A tenant-scoped aggregate
- Reads
- Saves
- Deliveries
- What the Kit guarantees
- Limits
- Testing
Declaring a tenant-scoped Module
The Proving Sample's Catalog, first in its Composition Root:
public void Configure(ModuleBuilder module)
{
// First, so the tenant requirement runs before every other authorizer of Catalog's commands and queries.
module.AddPouryaKitTenancy();
module.UsePouryaKitPostgres<CatalogDbContext>(connectionName: "app", schema: "catalog");
// ...
}AddPouryaKitTenancy()registers the tenant requirement, an authorizer on the pipeline's authorization hook, for every concrete command and query of the assembly of the Module's definition, internal ones included, and for every one a handler there handles, wherever it is declared, such as in the Module's Contracts: every command and query the Module can dispatch. An Operation that was begun with no tenant fails withTenantErrors.Required(Tenant.Required, kindForbidden) before its validator and handler run;ToHttpResult()answers it with 403 and the Error in the problem response'serrors. The scope opener must begin the Operation: in a scope nobody began, the requirement refuses withTenant.Requiredtoo, unless another authorizer of the operation, such as a permission check, needs the Operation Context first and throws. It covers only the assemblies it is given: a Module whoseAddPouryaKitCqrsscans another assembly too fails the Host when it is built (an assembly only another registration names, such asAddPouryaKitLocalization, makes nothing dispatchable and does not count), and the composition checkTenantRequirementChecknames the Module, each command or query it would dispatch without a tenant, and the assembly to name toAddPouryaKitTenancy.- It runs for a trusted continuation of one of those commands and queries too, unlike a permission check: a delayed command's run, a trusted peer's call, a command a Notification or Integration Event Handler sends during its delivery, or
Continue()inside the asking Operation, asICatalogProductsasks Catalog from another Module. Such a continuation runs with the tenant it carries, or is refused (the one exception to the rule that a trusted continuation is not authorized again). A delivery itself runs the Kit's own command, which is in no Feature assembly, so the requirement does not check it (Limits). - An Operation's authorizers run in the order registered, so declare it before
AddPouryaKitCqrsandAddPouryaKitPermissions: it then refuses a tenantless Operation before any authorizer of the Module's own reads data. - An application without Modules declares it on its own services and names its Feature assemblies, as it does for
AddPouryaKitCqrs:services.AddPouryaKitTenancy(typeof(Program).Assembly). With no assembly there it throwsArgumentException, since it would cover nothing. - The declaration is recorded for the Host:
HostComposition.IsTenantScoped(module), null for the application's own services, tells a Kit registration or composition check which Modules are tenant-scoped. - Two declarations fail the Host when it is built in a tenant-scoped Module. A recurring command (
RecurringCommandTenancyCheck): its occurrences run for a system Actor with no tenant, and the Kit runs none per tenant, so declare recurring work in a Module that is not tenant-scoped, as the Proving Sample's Inventory does; a delayed command is fine, since it continues the tenant of the Operation that scheduled it. A Snapshot of an aggregate of the Module's own DbContext whose mapped key neither includes itsTenantnor is a single Guid (SnapshotKeyTenancyCheck, withUsePouryaKitWolverine(connectionName)): the Snapshot's identity and version key hold the aggregate's key and no tenant, so key such an aggregate by a Guid version 7, as Catalog's products are, or by its tenant and its own code.
A tenant-scoped aggregate
internal sealed class Product : AggregateRoot<ProductId>, IAuditable, ISoftDeletable, ITenantScoped
{
public string Tenant { get; private set; } = "";
public static Result<Product> Create(ProductId id, CategoryId categoryId, string name, string tenant)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
// ...
var product = new Product { Id = id, Tenant = tenant, CategoryId = categoryId, Name = name.Trim() };
// ...
}
}
// Its handler takes the tenant from the Operation Context. Catalog is tenant-scoped, so it always has one.
internal sealed class CreateProductCommandHandler(IRepository<Product, ProductId> products, OperationContext context)
: CommandHandler<CreateProductCommand, Guid>
{
// ... Product.Create(new ProductId(command.ProductId), new CategoryId(command.CategoryId), command.Name, context.Tenant!)
}- The aggregate sets its own
Tenant, from the value its handler takes fromOperationContext.Tenant; the Kit never sets it. Declare it with a private setter, asIAuditable's properties are. Its column istenant, snake-cased like every other. - Implement
ITenantScopedon an aggregate or entity that is not mapped as an owned type, at the root of its inheritance hierarchy or below a root that implements it too, asISoftDeletableis: EF Core filters only those, so the model refuses to build otherwise, naming the type. - Map a part's relationship to its holder on the root of the part's hierarchy, and never let parts hold one another in a cycle: the model refuses either, naming the types, because no filter could leave out another tenant's parts, or the filters would read each other without end. A part may hold parts of its own type only with a required key to its aggregate or another holder: the model refuses it otherwise.
- A unique value is unique per tenant: Catalog's name index is on
(tenant, name), so two tenants each have a product of one name, and one tenant's name tells another nothing. - Make every aggregate of a tenant-scoped Module that holds a tenant's data tenant-scoped. Catalog's
Categoryis, because it counts the tenant's products.
Reads
- Every EF Core query of an
ITenantScopedentity returns only the rows of the tenant of the Operation running it, through a named query filter,PouryaKitDbContext.TenantFilter(PouryaKit.Tenant), besideSoftDeleteFilter. The repository'sFindAsync,ListAsync,AnyAsyncandCountAsyncand a specific repository's projection all read through it, so another tenant's aggregate is not found. A part of a tenant-scoped aggregate, at any level, has no tenant of its own, so the same named filter reads it only where its holder is read: an EF Core query of its own set (Set<Sheet>()) returns only the parts whose holder the Operation's tenant reads, by id or in a list, and a part of a removed holder is left out with it. A key to a part of its own type never judges the part, because EF Core reads that set unfiltered there; a null optional key to a holder does not judge the part while another key names a holder; a part with no holder is read by nobody; set the filter aside to find orphans. - The filter reads the tenant when each query runs, from the scope's Operation, never when the model is built or the DbContext created: the migration tool and the composition checks build a DbContext where no Operation began, and a DbContext made before its scope's Operation began reads that Operation's tenant. Where the Operation has no tenant, or none began, the filter reads no rows.
- A read that must cross tenants sets the filter aside on purpose, in an EF Core query over the DbContext:
IgnoreQueryFilters([PouryaKitDbContext.TenantFilter]). The repository's members never do. Only that filter is set aside: a removed row stays out unless the read namesSoftDeleteFiltertoo. - Dapper runs exactly the SQL you write, so a plain SQL read of a tenant-scoped table states
tenant = current_setting('pouryakit.tenant')itself, as Catalog'sGetProductandListProductsdo: the Kit's Dapper lease sets that session setting to the tenant of the scope's Operation when it opens, or to the empty string, which matches no row, where the Operation has none, so the handler binds no tenant and cannot bind the wrong one. The read keeps to the one shape a structure test holds every such read to, which rejectstenant = @Tenantor any other right-hand side: the predicate a top-level conjunct of theWHEREof oneSELECT, for each tenant-scoped table it reads (dapper-reads.md). A part's table has no tenant column, so a read of it joins it on its key to its holder, up to the aggregate's root held so. - A Module that is not tenant-scoped and keeps a tenant on its rows reads with the same predicate: Inventory's
GetStockItemreadsinventory.stock_itemsby product id andtenant = current_setting('pouryakit.tenant'), so another tenant's Actor and a tenantless Operation getStockItem.NotFound(Deliveries). Its table is noITenantScopedentity's, so no structure test holds the read to that shape and no filter or save check covers it: the predicate is the isolation.
Saves
- The Unit of Work's prepare step refuses the save with
TenantErrors.Mismatch(Tenant.Mismatch, kindForbidden) and rolls the Operation back when anITenantScopedaggregate it added belongs to another tenant than the Operation's, or one it changed or removed was loaded under another tenant, or had its tenant changed. It judges every aggregate the step attributes a change to: one whose part changed, both holders of a part moved between aggregates, and the parts a removal deletes with it. A removed soft-deletable aggregate is judged by the tenant it was loaded under, before it becomes a mark. - So a read that sets
TenantFilteraside may read another tenant's rows, but an Operation can never save a change to them: it answers 403 over HTTP. It holds wherever anITenantScopedaggregate is saved, as the filter does, in a Module that is tenant-scoped or not, and an Operation without a tenant saves none. - A tenant-scoped aggregate is loaded, never attached. An entity attached or updated rather than loaded carries the key and tenant its code gave it, so neither tells whose row its write would reach. The prepare step therefore refuses with
TenantErrors.NotLoaded(Tenant.NotLoaded, kindForbidden) a save that changes or removes anITenantScopedentity, or a part of one, that no query of the DbContext loaded, and one that changes an aggregate a change is attributed to that no query loaded; an added entity is new, not attached. An entity a query loaded counts as loaded only while that query's tracking of it lasts: detached, or cleared from the change tracker, and then attached again, it is attached. Load through the repository or an EF Core query. A tenant's aggregate whose own tenant names another tenant fails first withTenant.Mismatch. - Behind both refusals, a write that reaches the database is held to its tenant too: the model convention makes every tenant-scoped root entity's
Tenanta concurrency token besideVersion, so each UPDATE and DELETE of such a row matches it only under the tenant the entity was loaded with, and one under another tenant fails withUnitOfWork.ConcurrencyConflict. A part has no tenant, so its key to the entity that holds it is a concurrency token instead, at every level of a tenant-scoped aggregate: a loaded part's write matches its row only under the holder it was loaded with, the one the prepare step judged, so a part whose holder as loaded the Operation rewrote, by setting the key's original value or by accepting the change tracker's changes, fails the same way. A part moved between two of the tenant's aggregates is saved as before.
Deliveries
- A Notification, Integration Event or Snapshot delivered into a tenant-scoped Module runs under the tenant it carries, the one the publishing Operation had, so its handler writes for that tenant: Inventory, which is not tenant-scoped, keys its copy of Catalog's products by
(tenant, product_id), from the injectedOperationContext. - One that carries no tenant is refused before its Receipt and before its handler, whatever the handler writes with, plain SQL included: it fails with
Tenant.Required, is retried, then set aside as a dead letter whose message names it. The mistake is the publisher's, an Operation with no tenant whose work reaches a tenant-scoped Module; a replay is refused the same way. - A Module that is not tenant-scoped applies a delivery with or without a tenant and keeps the delivered tenant itself, as a plain property the Kit neither requires nor filters. Two Modules of the Proving Sample show it: Billing derives each welcome quote's id from the delivered tenant and the account (
OpenWelcomeQuoteIntegrationEventHandler.QuoteIdOf), and Inventory records it on each stock item it opens,StockItem.Tenant, nullable, from the injectedOperationContext, which itsGetStockItemendpoint then answers only to an Operation of that tenant (Reads). A stock item a tenantless delivery opened keeps no tenant and is answered to no Operation. Inventory's recurring reconciliation, which runs for a system Actor with no tenant, still counts every tenant's stock items, which a tenant-scoped Module could not declare (RecurringCommandTenancyCheck).
What the Kit guarantees
- In a tenant-scoped Module, no command or query runs without a tenant, a trusted continuation included; it fails with
Tenant.Requiredbefore its validator and handler. - An EF Core read of a tenant-scoped entity, or of a part of one through its own set, returns only the Operation's tenant's rows; a cross-tenant read names
TenantFilter. - An application without Modules that declares it on its own services gets the same requirement for the commands and queries of the assemblies it names.
- No Operation saves a change to another tenant's aggregate or to a part of one, or moves one between tenants, and nothing is saved: one it loaded fails with
Tenant.Mismatch, and a tenant-scoped aggregate or part it attached instead of loading fails withTenant.NotLoadedbefore the save, whoever's key it carries. - A tenant-scoped Module applies no delivery without a tenant; a delivery with one runs under it.
- A tenant-scoped Module composes no recurring command and no Snapshot whose key two tenants could share.
Limits
- An anonymous caller has no tenant, so no endpoint of a tenant-scoped Module serves one: Catalog's
ListProductsis authenticated, like every Catalog endpoint. A tenant comes only from a verified claim; a tenant taken from the host name or the route is not supported. - A plain SQL write is the Feature's too: no save check sees it. A structure test rejects SQL in a Module that writes a table an aggregate maps, its owned parts and child entities included (testing.md), so plain SQL writes only a Module's own tables that no aggregate maps, and writes the tenant such a table keeps itself, as Inventory's copy of Catalog's products does.
- Feature code that assembles the session tenant setting at run time is outside the structural tenant rules: the structure test that leaves
pouryakit.tenantto the lease rejects any SQL literal that writes session state, one holdingset_configor a statement that starts withSETorRESET, not such a statement built from fragments at run time, nor a database function the SQL calls that rebinds the setting. The Kit trusts Feature code within the Host. - A row written before its table kept the tenant cannot learn it later: a Module's own rows from before it was tenant-scoped have no tenant to recover, and a copy of another Module's data cannot be replayed, since the Snapshot versions already applied stop it. The migration that adds the tenant refuses a table that holds such a row rather than guess its tenant, as Catalog's
Tenantsand Inventory'sCatalogProductTenantsmigrations do; an empty tenant would hide the row from every Operation and fail any change to it. The Kit has no backfill. - Row-level security in PostgreSQL is not used, and the Receipt and Snapshot version keys hold no tenant.
Testing
- Prove isolation with two tenants at the Gateway: create in tenant A and in tenant B with
new OperationContext(actor, "a"), then show that A's get, list, change and removal of B's aggregate find nothing. - Prove a Dapper read the same way, through the Gateway one tenant after the other: A's read finds none of B's rows, B's, on a lease taken after A's from the same pool, finds B's, and an Operation without a tenant finds none.
- Over HTTP the tenant is the authenticated caller's claim: the test scheme issues it from its tenant header,
AppFixture.Client(caller, tenant, permissions). GatewayTestExtensions' calls without a context run with no tenant, so a tenant-scoped Module refuses them; pass a context with a tenant. The Proving Sample's tests addCatalogGatewayExtensions, the same calls for Catalog in a test tenant.- Show the requirement with a context without a tenant, and for a continuation with a context continued inside an Operation of a Module that is not tenant-scoped.
- Show the save check with two tenants: load the other tenant's aggregate with
TenantFilterset aside in a handler, change its root, a part, move a part in or out, remove it, and expectTenantErrors.Mismatchwith its rows unchanged; make the same change to the Operation's own tenant's to show it saves. Show the refusal of attached entities the same way: build an aggregate with a stored key and version and the Operation's tenant, or a part with the other tenant's part key under the Operation's own loaded aggregate, attach it and change it, update it or remove it, and expectTenantErrors.NotLoadedwith the rows unchanged. - Show a delivery refusal by sending to the subscription's queue with the Kit's headers and no tenant, then reading the dead letter.
- Show a delivered tenant kept by a Module that is not tenant-scoped end to end: create the product in tenant A in the Catalog Host of
BrokerFixture, wait forInventory.Hostto open its stock item, then read it over HTTP with bearer tokens of the tests' issuer, A's answered, B's and a token without the tenant claimNotFound.
Security
Declare the permission a command or query requires, answer which permissions an Actor holds, and optionally hash passwords and verify JWT bearer tokens.
Web
An endpoint beside each Feature, authenticated access by default, one problem response for every failure, and outgoing HTTP that carries the Operation Context.