From dee92eda24af85074d3f54a51f324b25ea7a7b84 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Mon, 20 Jul 2026 09:21:43 -0500 Subject: [PATCH] GH-3465: worked-example docs for conjoined EF Core tenancy, drawn from the sample app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conjoined EF Core multi-tenancy docs described the application-code payoff ("your handlers never touch TenantId") in prose but only ever showed synthetic registration snippets from the test project. This adds a "A Worked Example" section to the EF Core multi-tenancy page that shows the real end-to-end code — an ITenanted entity, a vanilla DbContext, HTTP tenant detection, a stamp-on-insert write endpoint, tenant-scoped read endpoint + message handler, and the CrossTenantWriteException write-side guard — all pulled as live, compile-verified snippets from the merged ConjoinedMultiTenantedEfCore sample app (which is in wolverine.slnx), plus a prominent link to the runnable sample. Follow-up #3531 filed for mixed-engine partitioning coverage (EF Core conjoined partitioning alongside Marten, and alongside Polecat, in one database). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guide/durability/efcore/multi-tenancy.md | 234 ++++++++++++++++++ .../Demos/CrossTenantWriteDemo.cs | 2 + .../Invoicing/Invoice.cs | 2 + .../Invoicing/InvoiceCreatedHandler.cs | 2 + .../Invoicing/InvoiceEndpoints.cs | 4 + .../Invoicing/InvoicingDbContext.cs | 2 + .../ConjoinedMultiTenantedEfCore/Program.cs | 2 + 7 files changed, 248 insertions(+) diff --git a/docs/guide/durability/efcore/multi-tenancy.md b/docs/guide/durability/efcore/multi-tenancy.md index b380c8b9d..260825afd 100644 --- a/docs/guide/durability/efcore/multi-tenancy.md +++ b/docs/guide/durability/efcore/multi-tenancy.md @@ -297,6 +297,240 @@ at the time it's created. If you need to query across tenants for administrative in your LINQ queries — but remember that the write-side guards will still stop you from modifying another tenant's data through a tenant-pinned `DbContext`. +### A Worked Example + +::: tip +The complete, runnable version of everything below — HTTP tenant detection, seeded tenants, a guided `curl` tour, and the +optional partitioning switch — is the [`ConjoinedMultiTenantedEfCore` sample application](https://github.com/JasperFx/wolverine/tree/main/src/Samples/ConjoinedMultiTenantedEfCore). +::: + +The whole point of conjoined tenancy is that your *application* code stops carrying tenancy plumbing. Start with an +ordinary entity — the only tenancy-related thing about it is the `ITenanted` marker — alongside an entity that is +deliberately left non-tenanted so it stays shared across every tenant: + + + +```cs +public class Invoice : ITenanted +{ + public Guid Id { get; set; } + public string Description { get; set; } = null!; + public decimal Amount { get; set; } + public InvoiceStatus Status { get; set; } = InvoiceStatus.Pending; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Wolverine maps, stamps, and hydrates this for you. Treat the + // value as framework-managed + public string? TenantId { get; set; } +} + +// Deliberately NOT ITenanted. Entities that don't implement the marker are left +// completely alone -- no tenant_id column, no query filter, no guard. Perfect +// for reference data shared by every tenant (think a common product catalog) +public class Product +{ + public Guid Id { get; set; } + public string Name { get; set; } = null!; + public decimal ListPrice { get; set; } +} +``` +snippet source | anchor + + +The `DbContext` is completely vanilla. There is no `tenant_id` mapping, no `HasQueryFilter()` to remember for each new +entity, no `SaveChanges` override, and no interceptor — Wolverine's model customizer applies all of that for you: + + + +```cs +public class InvoicingDbContext : DbContext +{ + public InvoicingDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Invoices { get; set; } = null!; + public DbSet Products { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(map => + { + map.ToTable("invoices", "invoicing"); + map.HasKey(x => x.Id); + }); + + modelBuilder.Entity(map => + { + map.ToTable("products", "invoicing"); + map.HasKey(x => x.Id); + }); + } +} +``` +snippet source | anchor + + +Detect the tenant once, where you configure Wolverine's HTTP endpoints. From here on nothing in your endpoints or +handlers ever looks at a header, a query string, or `TenantId`: + + + +```cs +app.MapWolverineEndpoints(opts => +{ + // Try headers first... + opts.TenantId.IsRequestHeaderValue("tenant-id"); + + // ...then fall back to a query string value, e.g. GET /invoices?tenant=acme + opts.TenantId.IsQueryStringValue("tenant"); + + // Any tenanted endpoint called without a detectable tenant id gets a 400 + // with ProblemDetails instead of quietly running against the default + // tenant. The /tenants administrative endpoints opt out with [NotTenanted] + opts.TenantId.AssertExists(); +}); +``` +snippet source | anchor + + +A write endpoint just adds the entity. It never reads a header, never sets `TenantId`, and never calls +`SaveChangesAsync()` — the tenant stamping interceptor supplies the tenant id and the [EF Core transactional +middleware](/guide/durability/efcore/transactional-middleware) commits both the row and the cascaded message through the durable outbox: + + + +```cs +[WolverinePost("/invoices")] +public static (CreationResponse, InvoiceCreated) Create( + CreateInvoice command, + InvoicingDbContext db) +{ + var invoice = new Invoice + { + Id = Guid.NewGuid(), + Description = command.Description, + Amount = command.Amount + }; + + db.Invoices.Add(invoice); + + var created = new InvoiceCreated(invoice.Id, invoice.Amount); + return (CreationResponse.For(created, $"/invoices/{invoice.Id}"), created); +} +``` +snippet source | anchor + + +Read endpoints are just as clean. There is no `Where(x => x.TenantId == ...)` anywhere — the global query filter binds +every query (and `FindAsync()`) to the detected tenant, so calling as `acme` can only ever see `acme`'s rows: + + + +```cs +[WolverineGet("/invoices")] +public static Task GetAll(InvoicingDbContext db) +{ + return db.Invoices.OrderBy(x => x.CreatedAt).ToArrayAsync(); +} + +// FindAsync respects the tenant filter as well -- asking for another +// tenant's invoice id returns null, which Wolverine.Http turns into a 404 +[WolverineGet("/invoices/{id}")] +public static Task GetById(Guid id, InvoicingDbContext db) +{ + return db.Invoices.FindAsync(id).AsTask(); +} +``` +snippet source | anchor + + +The `InvoiceCreated` message cascaded from that write endpoint carries the tenant id on its envelope, so a message +handler running later on a durable local queue — completely outside the original HTTP request — is tenant-scoped in +exactly the same way, with the same zero plumbing: + + + +```cs +public static class InvoiceCreatedHandler +{ + // Toy business rule: small invoices are approved automatically + public const decimal AutoApprovalLimit = 500; + + public static async Task Handle(InvoiceCreated message, InvoicingDbContext db, ILogger logger) + { + // Tenant-scoped load -- a message for tenant "acme" can never touch + // an "initech" invoice, even though both live in the same table + var invoice = await db.Invoices.FindAsync(message.InvoiceId); + if (invoice == null) + { + return; + } + + if (invoice.Amount <= AutoApprovalLimit) + { + invoice.Status = InvoiceStatus.Approved; + logger.LogInformation("Auto-approved invoice {InvoiceId} for tenant {TenantId}", + invoice.Id, invoice.TenantId); + } + else + { + logger.LogInformation("Invoice {InvoiceId} for tenant {TenantId} needs manual approval", + invoice.Id, invoice.TenantId); + } + } +} +``` +snippet source | anchor + + +Finally, the write-side guard. Even if application code deliberately smuggles another tenant's row out with +`IgnoreQueryFilters()`, modifying it is rejected at `SaveChanges` time with `CrossTenantWriteException` before anything +reaches the database: + + + +```cs +public static class CrossTenantWriteDemo +{ + [WolverinePost("/demos/cross-tenant-write")] + public static async Task Attempt(HijackInvoice command, InvoicingDbContext db) + { + // IgnoreQueryFilters() is the "one forgotten filter" from the motivating + // blog post, weaponized: it lets us see (and track) rows from every tenant + var smuggled = await db.Invoices.IgnoreQueryFilters() + .SingleOrDefaultAsync(x => x.Id == command.InvoiceId); + if (smuggled == null) + { + return new CrossTenantWriteAttempted(false, + $"No invoice with id {command.InvoiceId} exists for any tenant"); + } + + smuggled.Description = command.NewDescription; + + try + { + await db.SaveChangesAsync(); + + // Only reachable when the invoice already belongs to the calling tenant + return new CrossTenantWriteAttempted(false, + "The write succeeded because the invoice belongs to the calling tenant. " + + "Call this endpoint again with a different tenant-id header to see the rejection."); + } + catch (CrossTenantWriteException e) + { + // Nothing was written. Clear the poisoned change tracker so the + // transactional middleware's own SaveChangesAsync stays a no-op + db.ChangeTracker.Clear(); + + return new CrossTenantWriteAttempted(true, e.Message, e.EntityTenantId, e.ContextTenantId); + } + } +} +``` +snippet source | anchor + + ### Tenant Partitioning Opt into Weasel-managed **partition-per-tenant** physical partitioning with `PartitionPerTenant()`: diff --git a/src/Samples/ConjoinedMultiTenantedEfCore/Demos/CrossTenantWriteDemo.cs b/src/Samples/ConjoinedMultiTenantedEfCore/Demos/CrossTenantWriteDemo.cs index ddd300f55..13865ae1b 100644 --- a/src/Samples/ConjoinedMultiTenantedEfCore/Demos/CrossTenantWriteDemo.cs +++ b/src/Samples/ConjoinedMultiTenantedEfCore/Demos/CrossTenantWriteDemo.cs @@ -25,6 +25,7 @@ public record CrossTenantWriteAttempted( // // Try it: create an invoice as tenant "acme", then call this endpoint with the // invoice id as tenant "initech" +#region sample_conjoined_cross_tenant_write_rejection public static class CrossTenantWriteDemo { [WolverinePost("/demos/cross-tenant-write")] @@ -61,3 +62,4 @@ public static async Task Attempt(HijackInvoice comman } } } +#endregion diff --git a/src/Samples/ConjoinedMultiTenantedEfCore/Invoicing/Invoice.cs b/src/Samples/ConjoinedMultiTenantedEfCore/Invoicing/Invoice.cs index 52ab4dc9b..6bd5d73e7 100644 --- a/src/Samples/ConjoinedMultiTenantedEfCore/Invoicing/Invoice.cs +++ b/src/Samples/ConjoinedMultiTenantedEfCore/Invoicing/Invoice.cs @@ -24,6 +24,7 @@ public enum InvoiceStatus // Note that this class has zero tenancy logic of its own, and neither does the // DbContext mapping below. TenantId is framework-managed -- application code // should never write to it. +#region sample_conjoined_invoice_entity public class Invoice : ITenanted { public Guid Id { get; set; } @@ -46,3 +47,4 @@ public class Product public string Name { get; set; } = null!; public decimal ListPrice { get; set; } } +#endregion diff --git a/src/Samples/ConjoinedMultiTenantedEfCore/Invoicing/InvoiceCreatedHandler.cs b/src/Samples/ConjoinedMultiTenantedEfCore/Invoicing/InvoiceCreatedHandler.cs index 9e6d6edcd..205891151 100644 --- a/src/Samples/ConjoinedMultiTenantedEfCore/Invoicing/InvoiceCreatedHandler.cs +++ b/src/Samples/ConjoinedMultiTenantedEfCore/Invoicing/InvoiceCreatedHandler.cs @@ -13,6 +13,7 @@ namespace ConjoinedMultiTenantedEfCore.Invoicing; // * any write is stamped/guarded exactly like in the endpoint // // The transactional middleware saves and commits when the handler succeeds +#region sample_conjoined_tenant_scoped_handler public static class InvoiceCreatedHandler { // Toy business rule: small invoices are approved automatically @@ -41,3 +42,4 @@ public static async Task Handle(InvoiceCreated message, InvoicingDbContext db, I } } } +#endregion diff --git a/src/Samples/ConjoinedMultiTenantedEfCore/Invoicing/InvoiceEndpoints.cs b/src/Samples/ConjoinedMultiTenantedEfCore/Invoicing/InvoiceEndpoints.cs index d8b5f6bbc..eb5fd52de 100644 --- a/src/Samples/ConjoinedMultiTenantedEfCore/Invoicing/InvoiceEndpoints.cs +++ b/src/Samples/ConjoinedMultiTenantedEfCore/Invoicing/InvoiceEndpoints.cs @@ -26,6 +26,7 @@ public static class InvoiceEndpoints // The second tuple value is a cascaded message. It's published only after // the transaction commits, and it *carries the tenant id with it*, so the // message handler below is tenant-scoped too + #region sample_conjoined_stamp_on_insert_endpoint [WolverinePost("/invoices")] public static (CreationResponse, InvoiceCreated) Create( CreateInvoice command, @@ -43,6 +44,7 @@ public static (CreationResponse, InvoiceCreated) Create( var created = new InvoiceCreated(invoice.Id, invoice.Amount); return (CreationResponse.For(created, $"/invoices/{invoice.Id}"), created); } + #endregion // **5. Tenant-scoped queries through HTTP endpoints** // @@ -50,6 +52,7 @@ public static (CreationResponse, InvoiceCreated) Create( // Wolverine added to every ITenanted entity binds this query to the tenant // detected from the request. Call it as tenant "acme" and you only ever see // acme's invoices + #region sample_conjoined_tenant_scoped_query [WolverineGet("/invoices")] public static Task GetAll(InvoicingDbContext db) { @@ -63,4 +66,5 @@ public static Task GetAll(InvoicingDbContext db) { return db.Invoices.FindAsync(id).AsTask(); } + #endregion } diff --git a/src/Samples/ConjoinedMultiTenantedEfCore/Invoicing/InvoicingDbContext.cs b/src/Samples/ConjoinedMultiTenantedEfCore/Invoicing/InvoicingDbContext.cs index 3739d1c91..20a378d63 100644 --- a/src/Samples/ConjoinedMultiTenantedEfCore/Invoicing/InvoicingDbContext.cs +++ b/src/Samples/ConjoinedMultiTenantedEfCore/Invoicing/InvoicingDbContext.cs @@ -12,6 +12,7 @@ namespace ConjoinedMultiTenantedEfCore.Invoicing; // Wolverine's conjoined tenancy model customizer applies all of that // automatically to every entity implementing ITenanted when this context is // registered with AddDbContextWithWolverineManagedConjoinedTenancy() +#region sample_conjoined_vanilla_dbcontext public class InvoicingDbContext : DbContext { public InvoicingDbContext(DbContextOptions options) : base(options) @@ -36,3 +37,4 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) }); } } +#endregion diff --git a/src/Samples/ConjoinedMultiTenantedEfCore/Program.cs b/src/Samples/ConjoinedMultiTenantedEfCore/Program.cs index 93fa0b8b0..914e89703 100644 --- a/src/Samples/ConjoinedMultiTenantedEfCore/Program.cs +++ b/src/Samples/ConjoinedMultiTenantedEfCore/Program.cs @@ -109,6 +109,7 @@ // Wolverine.Http detects the tenant id from each request and flows it through // the endpoint, the DbContext, and any cascaded messages. The endpoints // themselves never look at headers or query strings +#region sample_conjoined_http_tenant_detection app.MapWolverineEndpoints(opts => { // Try headers first... @@ -122,5 +123,6 @@ // tenant. The /tenants administrative endpoints opt out with [NotTenanted] opts.TenantId.AssertExists(); }); +#endregion return await app.RunJasperFxCommands(args);