
Multi-tenancy has been one of the most common reasons clients bring JasperFx in, and it's a big part of why the Critter Stack has so much built-in support for it. If you're building a SaaS system on EF Core and would like someone who has done this a few times to look over your shoulder, we'd love to help. Much of the feature set shown in this post was built for a series of JasperFx clients over the past three years.
Milan Jovanović had a nice post on LinkedIn this week about the two EF Core features you really need for multi-tenancy: resolving connection strings dynamically per tenant, and using global query filters to keep every query pinned to the current tenant. The full article goes a step further and layers PostgreSQL row level security underneath the EF Core filters, because -- as one of the commenters on that post put it -- all it takes is one forgotten query filter for one tenant's invoices to show up on another tenant's dashboard. Good stuff, but that just triggered us to write about Wolverine and the rest of the Critter Stack's comprehensive support for developers using EF Core and especially our multi-tenancy support for EF Core.
What I want to do in this post is show you how much of that you don't have to build yourself if you're using Wolverine with EF Core, and, more importantly, how Wolverine connects EF Core multi-tenancy to everything else that happens in a real system: HTTP requests, the transactional inbox and outbox, messages cascading through handlers, and the database schema itself.
Because if you build the "hand rolled" version from Milan's post, here's what you end up owning yourself:
- A service that knows what the current tenant is, and some way to get it into every
DbContext - A
HasQueryFilter()call on every single tenanted entity, and the discipline to never forget one - Something that stamps the tenant id onto new rows before
SaveChanges() - Something that stops you from updating or deleting another tenant's row that you happened to load with
IgnoreQueryFilters() - A way to resolve the tenant's connection string per request if you're going database per tenant
- And then the part nobody talks about: getting that tenant id into the background work your system does after the request is over
Wolverine does all of that for you. Let's go through it. This one runs long, so here's the map:
- Conjoined multi-tenancy: one database, zero filter plumbing
- Database per tenant
- Which database engines?
- Adding tenants at runtime
- Tenant id detection in Wolverine.HTTP
- The transactional inbox and outbox, per tenant database
- The tenant id follows the message
- Domain events from EF Core entities
- "It just works" database migrations for every tenant
- Storage actions and
[Entity]are tenant-aware too - Wrapping up
- How JasperFx can help
Conjoined multi-tenancy: one database, zero filter plumbing
The simplest multi-tenancy model is what Marten has always called "conjoined" tenancy: every tenant shares one database and one set of tables, and every row carries a tenant_id column. This is the model Milan's post is about, and it's the one that gets you burned by a missing query filter.
Wolverine's version of this (conjoined multi-tenancy, new in Wolverine 6.21) hangs off of one marker interface that's shared across the whole Critter Stack. Marten, Polecat, and Wolverine's EF Core integration all key off the same ITenanted interface from JasperFx.MultiTenancy:
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;
// Wolverine maps, stamps, and hydrates this for you.
// Treat the value as framework-managed
public string? TenantId { get; set; }
}
// Deliberately NOT ITenanted, so it stays shared across every tenant.
// Think reference data or a common product catalog
public class Product
{
public Guid Id { get; set; }
public string Name { get; set; } = null!;
public decimal ListPrice { get; set; }
}The DbContext itself is completely vanilla. No tenant_id mapping, no HasQueryFilter(), no SaveChanges() override, no interceptors. Just your entities and your table mappings like you'd write on a single-tenant system. The registration is where the magic gets switched on:
builder.UseWolverine(opts =>
{
// One database for message persistence *and* all tenanted application data
opts.PersistMessagesWithPostgresql(connectionString);
opts.UseEntityFrameworkCoreTransactions();
opts.Policies.AutoApplyTransactions();
opts.Policies.UseDurableLocalQueues();
// Every ITenanted entity in this DbContext gets a tenant_id column,
// a tenant-bound global query filter, tenant stamping on insert,
// and cross-tenant write rejection -- with no tenancy code in the
// entities, the DbContext, or your handlers
opts.Services.AddDbContextWithWolverineManagedConjoinedTenancy<InvoicingDbContext>(
(builder, connectionString) => builder.UseNpgsql(connectionString.Value),
AutoCreate.CreateOrUpdate);
});With that one registration, Wolverine takes over every mechanical chore you'd otherwise be doing by hand:
- Every
ITenantedentity is mapped with atenant_idcolumn and an index on it - A global query filter binds every query -- including
FindAsync()-- to the tenant of the current message or HTTP request. There are no named filters for your team to remember, and no "one forgotten filter" to leak data - On
SaveChanges(), inserted entities are stamped with the ambient tenant id - Updates or deletes against a row belonging to a different tenant throw
CrossTenantWriteExceptioninstead of quietly crossing the line. Even if you deliberately smuggle another tenant's row out withIgnoreQueryFilters(), the write is rejected before it ever reaches the database - Sagas implementing
ITenantedare loaded per tenant, so the same saga id in two tenants is two different sagas
And your endpoint code? It looks like there's no multi-tenancy at all:
[WolverinePost("/invoices")]
public static (CreationResponse<InvoiceCreated>, InvoiceCreated) Create(
CreateInvoice command,
InvoicingDbContext db)
{
var invoice = new Invoice
{
Id = Guid.NewGuid(),
Description = command.Description,
Amount = command.Amount
};
// No TenantId assignment. No SaveChangesAsync(). Wolverine stamps the
// tenant and commits the row *and* the cascaded message together
db.Invoices.Add(invoice);
var created = new InvoiceCreated(invoice.Id, invoice.Amount);
return (CreationResponse.For(created, $"/invoices/{invoice.Id}"), created);
}
[WolverineGet("/invoices")]
public static Task<Invoice[]> GetAll(InvoicingDbContext db)
{
// There is no Where(x => x.TenantId == ...) anywhere. Calling as
// "acme" can only ever see acme's rows
return db.Invoices.OrderBy(x => x.CreatedAt).ToArrayAsync();
}A couple of notes before moving on. Conjoined tenancy builds the per-tenant DbContext through Wolverine's runtime code generation, so your application needs a reference to the WolverineFx.RuntimeCompilation package -- and Wolverine will fail fast at startup and tell you so if it's missing. And if you do want Milan's row level security belt on top of Wolverine's suspenders, that's perfectly compatible. The registration lambdas can hand you the active TenantId, so you can set_config() your way to a PostgreSQL policy if that's the level of assurance your system needs.
There's also an optional, Weasel-managed physical partitioning mode where every tenanted table gets a real database partition per tenant (list partitioning on PostgreSQL, range partitioning over a tenant ordinal on SQL Server), with the ability to bucket small tenants together into a shared partition. That's a post of its own, but the docs cover it well and the whole thing is runnable in the ConjoinedMultiTenantedEfCore sample in the Wolverine repository.
Database per tenant
Sometimes one shared database isn't good enough. Maybe it's regulatory, maybe it's a noisy neighbor problem, maybe you just have a couple of enormous customers who need their own hardware. Wolverine has had first class support for a database per tenant with EF Core since Wolverine 4, and the same DbContext type is used for every tenant:
builder.UseWolverine(opts =>
{
// You still need a "main" database for Wolverine's own bookkeeping
// about nodes, agents, and any non-tenanted work
opts.PersistMessagesWithPostgresql(configuration.GetConnectionString("main")!)
// Then register the tenants that are known up front
.RegisterStaticTenants(tenants =>
{
tenants.Register("tenant1", configuration.GetConnectionString("tenant1")!);
tenants.Register("tenant2", configuration.GetConnectionString("tenant2")!);
tenants.Register("tenant3", configuration.GetConnectionString("tenant3")!);
});
// At runtime Wolverine passes the right connection string for
// the active tenant into this lambda
opts.Services.AddDbContextWithWolverineManagedMultiTenancy<ItemsDbContext>(
(builder, connectionString, _) => builder.UseNpgsql(connectionString.Value),
AutoCreate.CreateOrUpdate);
});Swap PersistMessagesWithPostgresql() for PersistMessagesWithSqlServer() and UseNpgsql() for UseSqlServer() and you've got the SQL Server version. You can register as many DbContext types as you like this way, there's an overload that takes an NpgsqlDataSource per tenant if you're wiring things up through Aspire, and if you're already running Marten for event sourcing you can just let Marten own the tenant to database mapping and have EF Core ride along on the same databases.
That third lambda argument I'm ignoring with the _ above is the active TenantId, by the way. It's there for the folks who want a hybrid model -- separate databases for the big customers, shared databases with query filters for the long tail -- and it's how you'd feed the tenant id into EF Core's query filters in that case. The Multi-Tenancy with EF Core page has every overload.
Which database engines?
Wolverine's EF Core integration rides on top of Wolverine's own message persistence, and that persistence exists for five relational engines, each with its own multi-tenancy section in the docs:
| Engine | Package | Message store + tenancy docs |
|---|---|---|
| PostgreSQL | WolverineFx.Postgresql | PostgreSQL multi-tenancy |
| SQL Server | WolverineFx.SqlServer | SQL Server multi-tenancy |
| MySQL | WolverineFx.MySql | MySQL multi-tenancy |
| Oracle | WolverineFx.Oracle | Oracle multi-tenancy |
| SQLite | WolverineFx.Sqlite | SQLite multi-tenancy |
All five support the database per tenant model with both static registration and the master table tenancy I'll get to in a second, and all five give each tenant database its own inbox and outbox. The registration shape is identical across them; only the PersistMessagesWith*() call and the EF Core provider change. Here's the MySQL flavor, just to prove the point:
builder.UseWolverine(opts =>
{
opts.PersistMessagesWithMySql(configuration.GetConnectionString("main")!)
.RegisterStaticTenants(tenants =>
{
tenants.Register("tenant1", configuration.GetConnectionString("tenant1")!);
tenants.Register("tenant2", configuration.GetConnectionString("tenant2")!);
});
opts.Services.AddDbContextWithWolverineManagedMultiTenancy<ItemsDbContext>(
(builder, connectionString, _) =>
builder.UseMySql(connectionString.Value, ServerVersion.AutoDetect(connectionString.Value)),
AutoCreate.CreateOrUpdate);
});SQLite is a little special in that a "tenant database" is just a separate file, which makes it a surprisingly nice option for local development and for Fisher-style single node deployments.
I'll be honest about where the miles are, though. PostgreSQL and SQL Server are where the Critter Stack lives, they're where the bulk of the EF Core multi-tenancy test coverage is, and they're the only two engines where the conjoined model and its physical partitioning are supported today. If you're on MySQL, Oracle, or SQLite and want database per tenant, you're on a well documented path. If you want conjoined tenancy on one of those three, come talk to us and we'll find out together.
Adding tenants at runtime
Static registration is fine for a handful of tenants that never change. It is not fine for a SaaS product where sales signs a new customer on Tuesday and nobody wants to do a deployment to onboard them. For that, Wolverine has what we call master table tenancy, borrowed shamelessly from Marten's feature of the same name:
builder.UseWolverine(opts =>
{
opts.PersistMessagesWithPostgresql(configuration.GetConnectionString("wolverine")!)
// Tenant id to connection string mappings live in a
// wolverine_tenants table in the main database instead of
// in your configuration
.UseMasterTableTenancy(seed =>
{
// These registrations only seed data, which is handy for
// local development. You'd probably omit this in production
seed.Register("tenant1", configuration.GetConnectionString("tenant1")!);
seed.Register("tenant2", configuration.GetConnectionString("tenant2")!);
});
});Now the list of tenants is data. Wolverine exposes the registry through JasperFx's IDynamicTenantSource<string> abstraction, so you can add a new tenant from your own onboarding code:
public static async Task OnboardTenant(string tenantId, string connectionString, IDynamicTenantSource<string> tenants)
{
// Registers the tenant in the wolverine_tenants table. Wolverine builds
// the schema and starts a durability agent for the new database
await tenants.AddTenantAsync(tenantId, connectionString, CancellationToken.None);
}and Wolverine will discover the new database, build out its schema, and -- this is the important part -- spin up a durability agent for it so that the new tenant's inbox and outbox are being monitored just like the ones that were there at startup. Tenants can also be disabled (writes are rejected, data stays put), re-enabled, and removed. The conjoined model has the same registry and the same abstraction, it just doesn't need a connection string per tenant.
That IDynamicTenantSource<string> abstraction is also what lights up tenant management in CritterWatch, our commercial monitoring console for Critter Stack applications. CritterWatch's Tenants tab lets your operations folks add, disable, re-enable, remove, and (with a very deliberate confirmation step) hard delete tenants across a running application cluster from a browser, and breaks the service's traffic metrics down per tenant so you can see which customers are actually costing you. The Multi-Tenancy page in the CritterWatch docs covers the whole lifecycle, and I wrote about it at some length in Onboard a Tenant Without a Deployment. To be clear, CritterWatch is a paid product (plans are on our products page), but everything in Wolverine that makes it possible is open source and usable from your own code.
Tenant id detection in Wolverine.HTTP
Everything above depends on Wolverine knowing which tenant is active. In a web application, that starts at the edge. Wolverine.HTTP has a small set of composable "tenant id detection" strategies that you configure once, where you map the endpoints:
app.MapWolverineEndpoints(opts =>
{
// Detection falls through, so the first strategy that finds
// anything wins. Mix and match as you need
opts.TenantId.IsRequestHeaderValue("tenant-id");
opts.TenantId.IsClaimTypeNamed("tenant");
opts.TenantId.IsQueryStringValue("tenant");
opts.TenantId.IsRouteArgumentNamed("tenant");
opts.TenantId.IsSubDomainName();
// Any tenanted endpoint called without a detectable tenant id
// gets a 400 with ProblemDetails instead of quietly running
// against the default tenant
opts.TenantId.AssertExists();
});Endpoints that genuinely aren't tenanted -- health checks, the tenant administration endpoints themselves -- opt out with a [NotTenanted] attribute. If none of the built in strategies fit, ITenantDetection is a small interface to implement your own.
Here's where it all connects. When Wolverine detects the tenant id for a request, it sets that value on the MessageContext for the request before your endpoint method ever runs. The EF Core integration sees that tenant id and builds the DbContext that gets injected into your endpoint for that tenant: pointed at the right database in the database per tenant model, or pinned to the right tenant_id in the conjoined model. Your endpoint method takes an InvoicingDbContext argument and that's it. It never reads a header, it never asks for a TenantId, and it never touches a connection string. If you look at the code Wolverine generates around your endpoint, the tenant detection and the DbContext construction are right there in sequence, and you don't have to write either one.
The transactional inbox and outbox, per tenant database
This is the part that I think really separates Wolverine from doing multi-tenancy with EF Core by hand, or honestly from doing it with any other .NET messaging tool.
When you write a row and publish a message in the same handler, you want both of those things to happen or neither of them. That's the whole point of the transactional outbox. With a database per tenant, the outbox has to live in the tenant's database for that guarantee to hold, because a transaction can't span two databases. So Wolverine manages a completely separate inbox and outbox in every tenant database, plus the main database. When your POST /invoices endpoint for tenant acme adds an invoice and cascades an InvoiceCreated message, Wolverine's transactional middleware writes the invoice row and the outgoing envelope into acme's database in one transaction, then flushes the message to its destination after the commit. If the process dies in between, the durability agent for that database recovers the message later. Nothing is lost, and nothing is published for work that didn't commit.
There's a plethora of "Build your own lightsaber!" posts about rolling your own transactional outbox, but knowing how much effort it's been for the Critter Stack to handle scenarios like dynamic multi-tenancy, we're going to very strongly recommend you take Wolverine's off the shelf solution rather than finding out exactly how much complexity those blog posts leave out.
Speaking of durability agents: every message database, main or tenant, gets its own. In a cluster, Wolverine's agent distribution assigns each database's agent to exactly one node, and spreads them across the nodes you have. That's the mechanism that recovers stranded inbox and outbox messages, and since Wolverine 6.20 it's also what polls each tenant database for scheduled messages, so the polling load scales with the number of databases rather than databases times nodes. If a node goes down, its agents are reassigned to the survivors. If a tenant database is added at runtime, its agent is assigned as soon as Wolverine knows about it. You can see who owns what through Wolverine's normal agent diagnostics (or in CritterWatch's Durability tab) using the wolverinedb:// URI scheme. The leadership and troubleshooting docs go into the details.
And if you have existing code that isn't a Wolverine handler or endpoint -- an MVC controller, a hosted service, a legacy corner of the system -- you can still get the tenant-aware DbContext and the outbox together through IDbContextOutboxFactory:
public async Task HandleAsync(CreateItem command, TenantId tenantId, CancellationToken ct)
{
// A DbContext for this tenant, wrapped in a Wolverine outbox
var outbox = await _factory.CreateForTenantAsync<ItemsDbContext>(tenantId.Value, ct);
outbox.DbContext.Items.Add(new Item { Name = command.Name });
// Nothing actually goes out until the transaction succeeds
await outbox.PublishAsync(new ItemCreated(command.Name));
await outbox.SaveChangesAndFlushMessagesAsync(ct);
}For the conjoined model, there's only one database, so the messaging storage is just the plain message store and the outbox works exactly the way it does in a single tenant application. Nothing extra to configure.
The tenant id follows the message
Your system is more than just EF Core.
Now for the part that closes the loop. In Wolverine, the tenant id is message metadata. It rides on the envelope. When your HTTP endpoint cascades an InvoiceCreated message, that message is tagged with acme. When a handler picks it up a few milliseconds later on a durable local queue, or an hour later on a different node after a RabbitMQ hop, the tenant id is read back off the envelope, set on the MessageContext, and the exact same DbContext construction kicks in for that handler:
public static class InvoiceCreatedHandler
{
public static async Task Handle(InvoiceCreated message, InvoicingDbContext db)
{
// Tenant-scoped load. A message for "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 <= 500)
{
invoice.Status = InvoiceStatus.Approved;
}
}
}That handler has no idea it's multi-tenant. Any messages it cascades carry acme too, and so on down the chain. Establish the tenant once at the edge and it propagates through the whole workflow without anybody having to pass a tenantId argument through six layers of code.
You can override this when you need to:
// Retarget a cascading message at a different tenant
yield return new RecalculateTotals(invoiceId).WithTenantId("other-tenant");
// Or from IMessageBus, with DeliveryOptions...
await bus.PublishAsync(new RecalculateTotals(invoiceId), new DeliveryOptions { TenantId = "other-tenant" });
// ...or run a message inline for a specific tenant, which is very handy
// for "fan this maintenance job out to every tenant" from a scheduled job
await bus.InvokeForTenantAsync("other-tenant", new RecalculateTotals(invoiceId));But the default is the right thing, and it means the override is the exception you write on purpose rather than plumbing you write everywhere.
Domain events from EF Core entities
All of this is supported in our multi-tenancy model. Using the correct DbContext for the tenant, publishing messages through the transactional inbox/outbox, and propagating any domain events tagged to the current tenant id.
A lot of EF Core shops don't cascade messages from handlers at all. They use the classic .NET "domain events" pattern instead, where entities raise events into a collection on a layer supertype and something publishes them after SaveChangesAsync(). Wolverine supports that style directly, and it plugs into everything above. Start with the kind of base class you've probably already got:
public abstract class Entity
{
public List<object> Events { get; } = new();
public void Publish(object @event) => Events.Add(@event);
}
public class Invoice : Entity, ITenanted
{
public Guid Id { get; set; }
public InvoiceStatus Status { get; set; }
public string? TenantId { get; set; }
public void Approve()
{
Status = InvoiceStatus.Approved;
Publish(new InvoiceApproved(Id));
}
}Then one line of configuration tells Wolverine where the events live:
builder.UseWolverine(opts =>
{
opts.UseEntityFrameworkCoreTransactions();
// Scrape domain events off of any tracked Entity after SaveChanges()
// and publish them through Wolverine
opts.PublishDomainEventsFromEntityFrameworkCore<Entity>(x => x.Events);
opts.Policies.UseDurableLocalQueues();
});Now a handler that just calls invoice.Approve() and returns is enough. The transactional middleware walks the ChangeTracker after SaveChanges(), collects the events off every tracked entity, and enqueues them as outgoing messages inside the same transaction, so they go through the outbox exactly like a cascaded message would. And because they're published through the same message context, they carry the tenant id of the request or message that triggered them. An InvoiceApproved raised inside acme's DbContext is an acme message when its handler runs, with the acme DbContext waiting for it. There's no separate "domain event dispatcher" for you to make tenant-aware, because there's no separate dispatcher at all.
I'll admit I personally lean toward returning events from handlers as pure functions, because those are easier to unit test, but plenty of teams already have a domain model built this way and I'd rather Wolverine meet them where they are. I wrote more about the trade-offs in "Classic" .NET Domain Events with Wolverine and EF Core.
"It just works" database migrations for every tenant
Here's something I've noticed helping teams with EF Core: the very first thing that hurts about database per tenant is not the application code, it's the schema. Now you have N databases to create, N databases to migrate, and a brand new tenant database to stand up every time someone onboards. EF Core migrations can absolutely do that, but you're going to write the loop that does it, and you're going to run it in the right place at the right time.
The Critter Stack has always taken a different approach through Weasel, our schema management engine that sits under Marten, Polecat, and Wolverine. Weasel doesn't keep a migration history table; it reads what your model says the schema should be, compares that against what's actually in the database, and applies the difference. With one line, that applies to your EF Core DbContext types too:
builder.UseWolverine(opts =>
{
// Wolverine and Weasel manage the schema for every registered
// DbContext instead of EF Core migrations
opts.UseEntityFrameworkCoreWolverineManagedMigrations();
// Build out the message store and the DbContext tables on startup
opts.Services.AddResourceSetupOnStartup();
});Remember the AutoCreate.CreateOrUpdate argument on every one of the DbContext registrations above? That's the switch. With it, Wolverine will make sure each tenant database exists (Weasel knows how to create the catalog on both PostgreSQL and SQL Server), build the Wolverine inbox, outbox, and tenant tables in it, and build your EF Core entity tables in it, for every tenant it knows about, at startup or when the tenant is added at runtime. Clone the repository, docker compose up, dotnet run, and every database is there. The same thing is available from the command line if you'd rather control when it happens:
# See what Wolverine would change across every database, without changing anything
dotnet run -- db-assert
# Apply the outstanding schema changes to every known database
dotnet run -- db-applyI'll be upfront that Weasel's migrations are deliberately additive. They'll create tables and add columns, but they won't drop anything on their own, which is exactly what makes CreateOrUpdate safe to leave on. And plenty of shops have standardized on EF Core migrations for production deployments, with CI pipelines and review processes built around them. That's fine! Weasel can even generate EF Core migrations from your schema (including Wolverine's own message storage schema!) for you. But even if you keep EF Core migrations for the production move, the Critter Stack style is perfect for development and testing, where "the app builds its own schema across every known tenant database on the fly" means there's nothing to remember and nothing to run before you can press F5. That's the friction free integration testing story I've written about before, and it gets more valuable with every tenant database you add, not less.
One caveat worth knowing: the physical partitioning option for conjoined tenancy I mentioned earlier requires the Weasel-managed migrations, because EF Core migrations can't express the partition DDL.
And resetting the data between tests
Building the schema is half of the testing story. The other half is getting the database back to a known state between tests, and this is where Marten users have been spoiled for years by ResetAllData(). Wolverine's EF Core integration has the same thing now, registered for you by UseEntityFrameworkCoreTransactions():
[Fact]
public async Task approving_a_small_invoice()
{
// Wipe this DbContext's tables in foreign key safe order,
// then re-run every IInitialData<InvoicingDbContext> seeder
await _host.ResetAllDataAsync<InvoicingDbContext>();
// arrange ... act ... assert
}Weasel's database cleaner reads the tables straight out of the DbContext model, works out the foreign key ordering, and memoizes the provider-specific truncation SQL on first use so the reset is cheap enough to run before every test. Any IInitialData<T> seeders you've registered then lay down the baseline data, in registration order, so you can layer a suite-wide seeder with feature-specific ones. In the conjoined model that's the whole picture: one database, one reset, every tenant's rows gone and reseeded. I went deep on this in Clone, docker compose up, Go and the initial data docs cover the seeding patterns.
Storage actions and [Entity] are tenant-aware too
By the way, Wolverine can do much, much more than any other asynchronous messaging, HTTP endpoint, or "mediator" tool in the .NET ecosystem to simplify your application code, and we think this little section is a good example of that.
One last thing, because it's the kind of code I actually want to write. Wolverine's declarative persistence support -- the [Entity] attribute to load an entity by id from the incoming message, and IStorageAction<T> return values to tell Wolverine what to persist -- works with EF Core, and it respects the multi-tenancy in both models:
public static class ApproveInvoiceHandler
{
// Wolverine loads the Invoice from the current tenant's database
// (or pinned to the current tenant's rows), using the InvoiceId
// on the message. No DbContext in sight
public static Update<Invoice> Handle(ApproveInvoice command, [Entity] Invoice invoice)
{
invoice.Status = InvoiceStatus.Approved;
// And tells Wolverine to update it in that same tenant's storage
return Storage.Update(invoice);
}
}That handler is a pure function. You can unit test it with no database, no DbContext, and no tenant, and it will still do the right thing per tenant at runtime because the tenant id came in on the envelope and Wolverine did the rest.
Wrapping up
Milan's advice is good advice: use dynamic connection strings, use query filters, and consider row level security underneath. My argument is just that you shouldn't have to build the scaffolding around those primitives yourself, and you especially shouldn't have to build the part that carries the tenant through your HTTP layer, your outbox, your message handlers, and your schema management. With Wolverine, you mark your entities with ITenanted or register your tenant databases, configure tenant detection once at the edge, and write handlers and endpoints that look like they belong to a single tenant application. The Multi-Tenancy with EF Core docs and the multi-tenancy tutorial have the complete reference.
If you're newer to the EF Core side of the Critter Stack, we've been writing about it steadily this year:
- EF Core is Better with Wolverine is the overview of the whole EF Core story: transactional middleware, the outbox, runtime migrations, test resets, and declarative persistence
- Meet Weasel introduces the schema engine that makes the "it just works" migrations in this post possible
- Clone, docker compose up, Go is the friction free integration testing workflow, including the database cleaner and
IInitialData - Generate EF Core Migrations From Your Real Database Schema with Weasel is for the shops that keep EF Core migrations for production and want Weasel to write them
- One Application, Many Brokers covers the other side of Wolverine multi-tenancy, a message broker per tenant, which combines with everything here
- Onboard a Tenant Without a Deployment shows the runtime tenant management from the CritterWatch side
How JasperFx can help
Almost everything in this post came out of client work. Multi-tenancy is one of those areas where the decisions you make early -- shared database or separate, static or dynamic, which tenants get partitions -- are painful to unwind later, and it's also one of the most common things JasperFx gets asked to help with. Here's what we can do:
- Consulting -- architecture reviews of your actual system, help choosing between the tenancy models in this post, and hands-on work retrofitting tenancy into an EF Core system that wasn't designed for it
- Support plans -- direct access to the people who built these features, with guaranteed response times, so the question "why is tenant
acmeseeing a 400?" gets answered by someone who wrote the tenant detection code - CritterWatch -- the operations console for all of this, including the runtime tenant management and per-tenant metrics described above. It's an add-on to any support plan and included outright with the Premium tier
And as always, come find us in the Critter Stack Discord if you just want to talk it through first. It's almost always cheaper to talk to us before the system is on fire, but we're happy to help either way.


