Sponsored Content

DEV Community

Akintunde Morakinyo
Akintunde Morakinyo

Posted on

Multi-Tenancy Is More Than Adding a TenantId Column

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; }
}
Enter fullscreen mode Exit fullscreen mode

Queries then automatically include the current tenant:

SELECT *
FROM Customer
WHERE TenantId = @CurrentTenant
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

These records may legitimately be shared across every tenant.

And then there is the most obvious exception:

Tenant
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
{
}
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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
{
}
Enter fullscreen mode Exit fullscreen mode

or:

[TenantScoped(false)]
public class Country : DBModel
{
}
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

The ORM determines that Customer is tenant-scoped and effectively produces:

SELECT *
FROM Customer
WHERE TenantId = @CurrentTenant
Enter fullscreen mode Exit fullscreen mode

But:

await repository.Select<Country>();
Enter fullscreen mode Exit fullscreen mode

produces:

SELECT *
FROM Country
Enter fullscreen mode Exit fullscreen mode

The same rule needs to apply consistently to:

SELECT
INSERT
UPDATE
DELETE
COUNT
SEARCH
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

The infrastructure can attach the active tenant automatically.

But for:

var country = new Country
{
    Name = "Nigeria"
};

await repository.Save(country);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

It should behave more like:

MultiTenancyEnabled?
        │
        ▼
Is T tenant-scoped?
    │          │
   Yes         No
    │          │
Apply       Continue
Tenant      Normally
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Which models belong to tenants?
  2. Which models are global?
  3. 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)