Phase 1 of the conjoined EF Core multi-tenancy epic (#3465). Depends on JasperFx/jasperfx#531 (ITenanted promoted into JasperFx.MultiTenancy).
Motivating context: https://barretblake.dev/posts/development/2026/07/multi-tenant-part-1/ — hand-rolled shared-database tenancy in EF Core 10: a tenant-id column, named global query filters the team must remember everywhere, and the author's own warning that "one forgotten IgnoreQueryFilters() and Party A is reading Party B's mail." Wolverine should own all of that.
Goal
ITenanted on an EF entity ⇒ Wolverine makes it conjoined-multi-tenant (shared database, shared schema, tenant_id discriminator) with zero hand-written filters, behaviorally compliant with Marten/Polecat conjoined semantics.
Proposed design
New registration mode (name TBD), sibling of and mutually exclusive per-DbContext with the existing DB-per-tenant AddDbContextWithWolverineManagedMultiTenancy<T>:
services.AddDbContextWithWolverineManagedConjoinedTenancy<ItemsDbContext>(
(builder, connectionString) => builder.UseNpgsql(connectionString));
Components:
- Model convention in
WolverineModelCustomizer (already installed as EF's IModelCustomizer by every Wolverine registration path): for each entity implementing JasperFx.MultiTenancy.ITenanted — map TenantId → tenant_id (StorageConstants.TenantIdColumn) defaulting to *DEFAULT* (StorageConstants.DefaultTenantId), attach a global query filter bound to the context's current tenant, and index tenant_id. (PK stays the user's own in this phase; composite keys arrive with partitioning in Phase 2.)
SaveChangesInterceptor: on insert, stamp TenantId from the ambient Wolverine tenant (after TenantIdStyle correction); on update/delete, throw if the entry's TenantId differs from the context tenant (cross-tenant write rejection — the write-side counterpart of the query filter, matching Marten's conjoined session semantics).
- Conjoined
IDbContextBuilder<T>: single database (the app's message store connection), but every built DbContext is pinned to MessageContext.TenantId before handler code sees it. Because the existing EFCorePersistenceFrameProvider multi-tenant codegen branch keys off the IDbContextBuilder<> registration, the CreateTenantedDbContext<> / transaction frames work unchanged — conjoined vs DB-per-tenant differs only inside the builder.
- Tenant flow: nothing new —
Envelope.TenantId → MessageContext.TenantId, HTTP ITenantDetection policies, and the gRPC metadata detection all exist today.
- Conjoined sagas: a saga type implementing
ITenanted gets tenant-scoped load (verify early whether FindAsync/keyed loads respect global query filters; generate an explicit Where(id && tenant) load if not), stamping on insert, cross-tenant guard on update/delete. Optimistic-concurrency Version handling and SagaConcurrencyException wrapping are unchanged.
- Message storage untouched: conjoined = one database ⇒ the plain single message store; no
MultiTenantedMessageStore.
Verify-early risks
- EF
FindAsync vs global query filters (saga load correctness).
- EF filter-expression caching vs per-context-instance tenant state (filter must close over context state, not a model-build-time value); confirm interplay with EF 10 named filters so users can still declare their own.
Tests
New conjoined-tenancy battery (PG + SQL Server, connection strings via Servers), HTTP detection integration tests mirroring multi_tenancy_detection_and_integration.cs, and compliance assertions ported from Marten's conjoined tests: default-tenant sentinel, TenantIdStyle correction, stamp-on-write/hydrate-on-load, tenant-scoped deletes, cross-tenant rejection.
Independently shippable ahead of Phase 2 (partitioning) and Phase 3 (tenant registry + CritterWatch).
Phase 1 of the conjoined EF Core multi-tenancy epic (#3465). Depends on JasperFx/jasperfx#531 (
ITenantedpromoted intoJasperFx.MultiTenancy).Motivating context: https://barretblake.dev/posts/development/2026/07/multi-tenant-part-1/ — hand-rolled shared-database tenancy in EF Core 10: a tenant-id column, named global query filters the team must remember everywhere, and the author's own warning that "one forgotten
IgnoreQueryFilters()and Party A is reading Party B's mail." Wolverine should own all of that.Goal
ITenantedon an EF entity ⇒ Wolverine makes it conjoined-multi-tenant (shared database, shared schema,tenant_iddiscriminator) with zero hand-written filters, behaviorally compliant with Marten/Polecat conjoined semantics.Proposed design
New registration mode (name TBD), sibling of and mutually exclusive per-DbContext with the existing DB-per-tenant
AddDbContextWithWolverineManagedMultiTenancy<T>:Components:
WolverineModelCustomizer(already installed as EF'sIModelCustomizerby every Wolverine registration path): for each entity implementingJasperFx.MultiTenancy.ITenanted— mapTenantId→tenant_id(StorageConstants.TenantIdColumn) defaulting to*DEFAULT*(StorageConstants.DefaultTenantId), attach a global query filter bound to the context's current tenant, and indextenant_id. (PK stays the user's own in this phase; composite keys arrive with partitioning in Phase 2.)SaveChangesInterceptor: on insert, stampTenantIdfrom the ambient Wolverine tenant (afterTenantIdStylecorrection); on update/delete, throw if the entry'sTenantIddiffers from the context tenant (cross-tenant write rejection — the write-side counterpart of the query filter, matching Marten's conjoined session semantics).IDbContextBuilder<T>: single database (the app's message store connection), but every built DbContext is pinned toMessageContext.TenantIdbefore handler code sees it. Because the existingEFCorePersistenceFrameProvidermulti-tenant codegen branch keys off theIDbContextBuilder<>registration, theCreateTenantedDbContext<>/ transaction frames work unchanged — conjoined vs DB-per-tenant differs only inside the builder.Envelope.TenantId→MessageContext.TenantId, HTTPITenantDetectionpolicies, and the gRPC metadata detection all exist today.ITenantedgets tenant-scoped load (verify early whetherFindAsync/keyed loads respect global query filters; generate an explicitWhere(id && tenant)load if not), stamping on insert, cross-tenant guard on update/delete. Optimistic-concurrencyVersionhandling andSagaConcurrencyExceptionwrapping are unchanged.MultiTenantedMessageStore.Verify-early risks
FindAsyncvs global query filters (saga load correctness).Tests
New conjoined-tenancy battery (PG + SQL Server, connection strings via
Servers), HTTP detection integration tests mirroringmulti_tenancy_detection_and_integration.cs, and compliance assertions ported from Marten's conjoined tests: default-tenant sentinel,TenantIdStylecorrection, stamp-on-write/hydrate-on-load, tenant-scoped deletes, cross-tenant rejection.Independently shippable ahead of Phase 2 (partitioning) and Phase 3 (tenant registry + CritterWatch).