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);