Multi-tenancy often looks straightforward at the beginning.
You have multiple organizations using the same application and database. Add a TenantId to each record, automatically filter queries by the current tenant, and make sure every insert carries the correct tenant.
Problem solved.
At least, that was the assumption I started with while working on multi-tenancy support for SimpleORM.Net, an ORM project I have been building for .NET.
Then I encountered a simple question:
Should the Tenant model itself have a TenantId?
Obviously, it shouldn't.
Then came another question:
What about Country?
If my application contains a list of countries, should every tenant have its own copy of Nigeria, Canada, Germany, the United Kingdom, and every other country?
Again, probably not.
That was when a seemingly simple feature became a much more interesting architectural problem.
The Initial Model
A common approach to multi-tenancy is to make every business record tenant-aware.
Conceptually, our models might look like this:
public class Customer : DBModel
{
public string Name { get; set; }
public string TenantId { get; set; }
}
Queries then automatically include the current tenant:
SELECT *
FROM Customer
WHERE TenantId = @CurrentTenant
And when a new customer is saved, the ORM automatically assigns the current tenant.
This is useful because developers don't have to remember tenant filtering every time they write a query.
Without centralized enforcement, it only takes one forgotten condition to create a serious data-isolation problem.
So automatic tenant enforcement makes sense.
But there is a problem with applying that rule universally.
Not Every Model Belongs to a Tenant
Consider these models:
Customer
Order
Invoice
Country
Currency
Tenant
Customer clearly belongs to a tenant.
An Order probably belongs to a tenant.
An Invoice probably belongs to a tenant.
But Country is different.
Nigeria doesn't become a different country because Tenant A and Tenant B are using the system.
The same is true for many reference datasets:
Country
Currency
Language
TimeZone
Industry
These records may legitimately be shared across every tenant.
And then there is the most obvious exception:
Tenant
Making Tenant require a TenantId would mean that a tenant belongs to another tenant.
That is clearly the wrong abstraction.
The mistake is assuming:
Multi-tenant application
=
Every model is tenant-scoped
Those are two different things.
Tenant Scope Is a Property of the Model
A better mental model is:
Application
│
Multi-Tenancy Enabled
│
┌───────────┴───────────┐
│ │
Tenant-Scoped Global
Models Models
│ │
Customer Country
Order Currency
Invoice Tenant
Multi-tenancy can still be enabled for the application.
But individual models determine whether tenant isolation applies to them.
This changes the responsibility of the ORM.
Instead of asking:
Is multi-tenancy enabled?
It needs to ask:
Is multi-tenancy enabled, and is this particular model tenant-scoped?
That small distinction makes the architecture considerably more flexible.
Where Should This Information Live?
Once I reached this point, another design decision appeared.
How should a model declare that it is global?
There are several possibilities.
Option 1: Marker Interface
For example:
public class Country : DBModel, IGlobalModel
{
}
The ORM could check whether a model implements IGlobalModel.
This works, but an interface normally communicates behaviour or a contract.
In this case, we aren't really introducing behaviour.
We're describing metadata about how the model should be persisted.
Option 2: Configuration
Another approach is configuration:
options.MultiTenancy
.Exclude<Country>()
.Exclude<Currency>()
.Exclude<Tenant>();
This provides excellent flexibility.
It is particularly useful when an application needs to change ORM behaviour without modifying the model.
However, it also means that understanding the behaviour of Country requires finding the application's ORM configuration.
Looking at the model alone doesn't tell you whether it is tenant-scoped.
Option 3: Model Metadata
The third approach is to make tenant scope part of the model's metadata.
Conceptually:
[GlobalModel]
public class Country : DBModel
{
}
or:
[TenantScoped(false)]
public class Country : DBModel
{
}
Now the intent travels with the model.
Wherever Country is used, the ORM can inspect its metadata and know that tenant enforcement should not apply.
For an ORM, I find this particularly attractive because tenant behaviour becomes part of the model definition in the same way that keys, uniqueness, table mappings and other persistence rules are model metadata.
Enforcement Still Belongs in the ORM
Allowing global models should not mean pushing tenant filtering back to application developers.
That would defeat one of the major advantages of ORM-level multi-tenancy.
The application should still be able to write something conceptually as simple as:
await repository.Select<Customer>();
The ORM determines that Customer is tenant-scoped and effectively produces:
SELECT *
FROM Customer
WHERE TenantId = @CurrentTenant
But:
await repository.Select<Country>();
produces:
SELECT *
FROM Country
The same rule needs to apply consistently to:
SELECT
INSERT
UPDATE
DELETE
COUNT
SEARCH
That consistency matters.
If SELECT applies tenant isolation but UPDATE doesn't, the architecture is still unsafe.
Saving Records Requires the Same Decision
The distinction becomes equally important during writes.
For a tenant-scoped model:
var customer = new Customer
{
Name = "ABC Limited"
};
await repository.Save(customer);
The infrastructure can attach the active tenant automatically.
But for:
var country = new Country
{
Name = "Nigeria"
};
await repository.Save(country);
there should be no requirement for tenant information.
This means tenant enforcement shouldn't simply be buried inside a generic Save<T>() method as:
MultiTenancyEnabled?
→ Require Tenant
It should behave more like:
MultiTenancyEnabled?
│
▼
Is T tenant-scoped?
│ │
Yes No
│ │
Apply Continue
Tenant Normally
The difference looks small in code.
Architecturally, it is significant.
Shared Data Introduces Another Question
Once global models exist, another interesting scenario becomes possible.
What if some records of a model are global while others are tenant-specific?
Products are a good example.
Imagine a platform provides standard products that every tenant can use, but tenants can also create their own products.
Now the query might need to represent:
TenantId = CurrentTenant
OR
TenantId IS NULL
That is a different problem from a completely global model such as Country.
So there are potentially three concepts:
Tenant-Scoped Model
Every record belongs to one tenant.
Global Model
Records belong to no tenant and are available to everyone.
Shared Model
Global records may coexist with tenant-specific records.
I would resist implementing all three simply because they are possible.
An important part of architecture is knowing when not to generalize.
If the current requirement only needs tenant-scoped and global models, those are the concepts the system should implement.
The design should leave room for shared records later without forcing that complexity into today's API.
The Bigger Lesson
The interesting part of this problem wasn't adding another attribute or another condition to a query.
It was discovering that the original abstraction was slightly wrong.
The first abstraction was:
The application is multi-tenant, therefore its data is tenant-scoped.
The better abstraction became:
The application supports multi-tenancy, while individual models define their tenancy behaviour.
That distinction prevents strange models such as:
Tenant → TenantId
Country → TenantId
Currency → TenantId
while preserving automatic isolation where it actually matters.
This is something I repeatedly encounter when designing reusable infrastructure.
The first version of an abstraction often describes the most common case.
The difficult part is discovering whether that common case is actually a universal rule.
Final Thoughts
Adding TenantId is easy.
Designing where tenant boundaries belong is the harder problem.
A good multi-tenancy implementation needs to answer at least three questions:
- Which models belong to tenants?
- Which models are global?
- Where is tenant isolation enforced?
For SimpleORM.Net, the direction is to keep enforcement inside the ORM while allowing model metadata to determine whether a particular model participates in tenant isolation.
That keeps application code simple without pretending that every piece of data has the same ownership model.
And sometimes that is the difference between adding multi-tenancy as a feature and actually designing for multi-tenancy.
Top comments (0)