Make a Module tenant-scoped
Require a tenant for every command and query of a Module, and read and save its aggregates only within the tenant of the Operation.
What you build
A tenant-scoped Module runs no Operation and applies no delivery without a tenant, and its tenant-scoped aggregates are read and saved only by Operations of the tenant they belong to. It is a registration across packages the Host already has, not a package of its own. In the Proving Sample, Catalog and Ledger are tenant-scoped.
A tenant comes only from a verified claim
At the HTTP boundary the tenant is read from the claim the bearer scheme verified, never from a host name or a route. So a tenant-scoped Module maps no public endpoint: an anonymous caller has no tenant, and its Operation would be refused.
When to use it
When one Host serves several customers or companies and each must see and change only its own data. A Module that is not tenant-scoped can still keep a delivered tenant on its own rows, as Inventory does: see Deliveries.
Steps
Declare the Module tenant-scoped, first
Call AddPouryaKitTenancy() first in the Module's Composition Root, before AddPouryaKitCqrs and AddPouryaKitPermissions. It registers the tenant requirement for every command and query the Module can dispatch, and a requirement registered first refuses a tenantless Operation before any authorizer of the Module's own reads data.
Mark each aggregate that holds a tenant's data
Implement ITenantScoped on the aggregate, with a Tenant property it sets itself, from the tenant its handler takes from the Operation Context. The Kit never sets it.
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;
}Make a unique value unique per tenant: Catalog's name index is on the tenant and the name together. Then add a migration for the new column and index.
State the tenant in every plain SQL read
EF Core reads of a tenant-scoped entity are filtered to the Operation's tenant for you. Dapper runs exactly the SQL you write, so a read of a tenant-scoped table holds each such table to the tenant itself, against the tenant the Kit's connection lease set, never one the handler passes. A structure test holds every such read to one grammar: Reading gives the predicate and the grammar.
internal sealed class ListProductsQueryHandler(IReadConnectionFactory connections)
: QueryHandler<ListProductsQuery, Page<ProductListItemDto>>
{
// The Feature writes its WHERE, the tenant the lease bound included, since plain SQL reads no query filter; each
// declared filter arrives as @{field}_{operator}, null when not asked for.
private const string From = """
from catalog.products p
where p.tenant = current_setting('pouryakit.tenant')
and not p.is_deleted
and (@name_eq is null or p.name = @name_eq)
and (@name_contains is null or strpos(p.name, @name_contains) > 0)
and (@category_eq is null or p.category_id = @category_eq)
and (@created_gte is null or p.created_at >= @created_gte)
""";
private static readonly PageSql Sql = new PageSql(
ListProductsQuery.Paging,
select: $"select p.id, p.category_id as categoryid, p.name {From}",
count: $"select count(*) {From}"
)
.OrderBy("name", "p.name")
.OrderBy("created", "p.created_at")
.ThenBy("p.id");
public override async Task<Result<Page<ProductListItemDto>>> Handle(
ListProductsQuery query,
CancellationToken cancellationToken
)
{
await using var connection = await connections.OpenAsync(cancellationToken);
return await connection.QueryPageAsync<ProductListItemDto>(
Sql,
query.Page,
cancellationToken: cancellationToken
);
}
}Prove isolation with two tenants
Create in one tenant and in another through the Gateway, each with a context of its own tenant, then show that one tenant's get, list, change and removal find none of the other's:
[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"
)
);
}What the Kit guarantees
| Guarantee | Stated in |
|---|---|
| No command or query runs without a tenant, a trusted continuation included | What the Kit guarantees |
| EF Core reads see only the Operation's tenant's rows | Reads |
| No Operation saves another tenant's aggregate | Saves |
| No delivery is applied without a tenant | Deliveries |
| What a tenant-scoped Module cannot declare | Declaring a tenant-scoped Module |