Skip to content

Conjoined EF Core multi-tenancy, Phase 1: ITenanted core + conjoined sagas #3462

Description

@jeremydmiller

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:

  1. Model convention in WolverineModelCustomizer (already installed as EF's IModelCustomizer by every Wolverine registration path): for each entity implementing JasperFx.MultiTenancy.ITenanted — map TenantIdtenant_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.)
  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).
  3. 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.
  4. Tenant flow: nothing new — Envelope.TenantIdMessageContext.TenantId, HTTP ITenantDetection policies, and the gRPC metadata detection all exist today.
  5. 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.
  6. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions