Every SaaS makes a decision about tenancy whether it means to or not. Get it right early and tenants are a column, a connection, and a config lookup. Get it wrong and you are retrofitting isolation into a system that assumed a single customer - one of the most expensive refactors there is. On .NET, Finbuckle.MultiTenant gives you the primitives to get it right from the start.
Three problems, one library
Multi-tenancy is really three problems wearing a trench coat:
- Resolution - given a request, which tenant is this?
- Configuration - what settings, branding, and limits apply to that tenant?
- Isolation - how do we guarantee one tenant never sees another's data?
Finbuckle addresses all three with a tenant context that it resolves per request and flows through the rest of the pipeline.
Resolving the tenant
Resolution strategies are pluggable. The common ones are host (each tenant gets a subdomain), route (a path segment), or header. Register the strategy and a store that knows your tenants:
builder.Services.AddMultiTenant<AppTenantInfo>()
.WithHostStrategy()
.WithConfigurationStore();
The AppTenantInfo is your own type - extend it with whatever a tenant needs to carry: a connection string, a plan tier, feature flags, a brand palette.
Per-tenant configuration
Once resolved, the tenant info is available through dependency injection anywhere downstream. That turns per-tenant behavior into a property read rather than a special case scattered through the code:
public sealed class ReportLimiter(IMultiTenantContextAccessor<AppTenantInfo> accessor)
{
public int MaxRows => accessor.MultiTenantContext.TenantInfo?.PlanTier switch
{
"enterprise" => 1_000_000,
"team" => 100_000,
_ => 10_000,
};
}
This is the part teams underestimate: most of what makes tenants different is configuration, and centralizing it here keeps the difference out of your domain logic.
Data isolation strategies
Isolation is where the real architecture decision lives, and Finbuckle supports the spectrum:
- Shared database, shared schema - a
TenantIdcolumn on every table, with a global query filter so you physically cannot forget the where clause. Cheapest to run, requires the most discipline. - Shared database, separate schema - more isolation, moderate cost.
- Database per tenant - the strongest isolation and the easiest compliance story, resolved by swapping the connection string from the tenant info.
With EF Core, the shared-schema approach leans on a query filter that Finbuckle wires from the ambient tenant:
modelBuilder.Entity<Invoice>()
.HasQueryFilter(i => i.TenantId == _tenantId);
Warning: A global query filter is a safety net, not a license to be careless. Raw SQL and certain bulk operations bypass it - audit those paths explicitly.
Picking a strategy
There is no universally right answer, only trade-offs. Shared schema scales to many small tenants cheaply but concentrates blast radius. Database-per-tenant is simpler to reason about and easier to certify, but it costs more to operate and complicates cross-tenant analytics. A common middle path - and the one we often recommend - is shared schema for the long tail and dedicated databases for large or regulated tenants, all behind the same Finbuckle abstraction so the application code does not change.
Wrapping up
Multi-tenancy stops being scary when you separate its three concerns and let a library carry the ambient tenant for you. Finbuckle handles resolution and isolation cleanly, which frees you to make the one decision that actually matters - your isolation strategy - deliberately rather than by accident. Decide it before your first paying tenant, and the rest is configuration.
Building a multi-tenant platform and weighing the isolation trade-offs? Let's discuss your architecture.
