Mastering Multi-Tenancy with the Unit of Work Pattern
Building a SaaS application means one thing above all: serving multiple customers (tenants) from the same system. Multi-tenancy gives us efficiency and scalability, but it also comes with challenges around data isolation, transaction safety, and maintainability.
That’s where the Unit of Work (UoW) pattern comes in. Together, multi-tenancy and Unit of Work form a strong foundation for building secure, scalable, and clean SaaS backends.
1. Quick Recap: What is Multi-Tenancy?
Multi-tenancy is a software architecture pattern where a single application instance serves multiple tenants. Each tenant is logically isolated, but the underlying code and infrastructure are shared.
Benefits
- Cost Efficiency: Shared infrastructure reduces hosting and maintenance costs.
- Scalability: Scaling one app is easier than managing multiple isolated instances.
- Centralized Management: One codebase for all tenants means faster updates and bug fixes.
2. Multi-Tenancy Models
There are three common models for implementing multi-tenancy:
-
Database-per-Tenant
- Each tenant gets its own database.
- Pros: Strong isolation, easy data backup.
- Cons: Harder to manage at scale (many connection strings, migrations).
-
Schema-per-Tenant
- All tenants share a database, but each tenant has its own schema.
- Pros: Balance between isolation and management.
- Cons: Still requires schema management complexity.
-
Shared Schema
- All tenants share one database and one schema. Tenant rows are separated by a
TenantId
column. - Pros: Resource-efficient, simplest to scale horizontally.
- Cons: Requires strict query filters to avoid data leakage.
- All tenants share one database and one schema. Tenant rows are separated by a
3. What is the Unit of Work Pattern?
The Unit of Work pattern is a way to track changes during a business transaction and commit them as a single unit.
Instead of repositories or services committing changes individually, Unit of Work:
- Groups all changes under one transaction.
- Commits them together (atomicity).
- Rolls back safely on errors.
Think of it like a "shopping cart" for database changes: you gather all the operations you need, then check out (commit) once at the end.
4. Why Combine Multi-Tenancy with Unit of Work?
Multi-tenancy introduces complexity: each request belongs to a tenant, and that tenant’s data must be isolated in every transaction.
Here’s why Unit of Work helps:
- Tenant Isolation: Each UoW instance is scoped to one tenant.
- Atomic Transactions: Ensures tenant-specific changes commit together.
- Centralized Tenant Context: Inject tenant info automatically into repositories.
- Cleaner Codebase: Keeps data access and tenant handling consistent across the app.
5. Architecture Design
Here’s a high-level architecture combining both patterns:
1
2\[ Request ]
3↓
4\[ Tenant Resolver Middleware ]
5↓
6\[ Application Layer ]
7↓
8\[ Unit of Work (per tenant request) ]
9↓
10\[ Repositories with Tenant Context ]
11↓
12\[ Database (per-tenant / schema / shared schema) ]
13
Flow
- Resolve TenantId from subdomain, request header, or JWT.
- Initialize a Unit of Work bound to that tenant.
- Repositories auto-apply the tenant context.
- At the end of the request, commit or rollback the UoW.
6. Implementation in C#
Here’s a simplified version using Entity Framework Core.
Tenant Context
1public class TenantContext
2{
3 public string TenantId { get; set; }
4 public string ConnectionString { get; set; }
5}
Unit of Work
1public interface IUnitOfWork : IDisposable
2{
3 Task<int> CommitAsync();
4}
1public class UnitOfWork : IUnitOfWork
2{
3 private readonly TenantDbContext _context;
4
5 public UnitOfWork(TenantDbContext context)
6 {
7 _context = context;
8 }
9
10 public async Task<int> CommitAsync()
11 {
12 return await _context.SaveChangesAsync();
13 }
14
15 public void Dispose() => _context.Dispose();
16}
Tenant-Aware DbContext
1public class TenantDbContext : DbContext
2{
3 private readonly TenantContext _tenant;
4
5 public TenantDbContext(DbContextOptions<TenantDbContext> options, TenantContext tenant)
6 : base(options)
7 {
8 _tenant = tenant;
9 }
10
11 protected override void OnModelCreating(ModelBuilder modelBuilder)
12 {
13 // Apply tenant filter for shared-schema model
14 foreach (var entity in modelBuilder.Model.GetEntityTypes())
15 {
16 if (typeof(ITenantEntity).IsAssignableFrom(entity.ClrType))
17 {
18 modelBuilder.Entity(entity.ClrType).HasQueryFilter(
19 e => EF.Property<string>(e, "TenantId") == _tenant.TenantId
20 );
21 }
22 }
23 base.OnModelCreating(modelBuilder);
24 }
25}
Repository Example
1public interface ICustomerRepository
2{
3 Task AddAsync(Customer customer);
4 Task<Customer?> GetByIdAsync(Guid id);
5}
1public class CustomerRepository : ICustomerRepository
2{
3 private readonly TenantDbContext _context;
4
5 public CustomerRepository(TenantDbContext context)
6 {
7 _context = context;
8 }
9
10 public async Task AddAsync(Customer customer)
11 {
12 customer.TenantId = _context.TenantId; // enforce tenant scope
13 await _context.Customers.AddAsync(customer);
14 }
15
16 public async Task<Customer?> GetByIdAsync(Guid id)
17 {
18 return await _context.Customers.FirstOrDefaultAsync(c => c.Id == id);
19 }
20}
Application Service Example
1public class CustomerService
2{
3 private readonly ICustomerRepository _repository;
4 private readonly IUnitOfWork _unitOfWork;
5
6 public CustomerService(ICustomerRepository repository, IUnitOfWork unitOfWork)
7 {
8 _repository = repository;
9 _unitOfWork = unitOfWork;
10 }
11
12 public async Task CreateCustomerAsync(Customer customer)
13 {
14 await _repository.AddAsync(customer);
15 await _unitOfWork.CommitAsync();
16 }
17}
7. Challenges & Best Practices
- Tenant Context Propagation: Always resolve tenant at the request boundary. Don’t pass it around manually.
- Transaction Boundaries: One Unit of Work per request is a good rule of thumb.
- Performance Monitoring: Track per-tenant query load to avoid “noisy neighbor” issues.
- Security: Enforce tenant filters at the ORM level to avoid accidental cross-tenant queries.
- Testing: Write integration tests simulating multiple tenants to ensure isolation.
8. Conclusion
Combining multi-tenancy architecture with the Unit of Work pattern gives your SaaS application:
- Strong data isolation.
- Reliable transaction safety.
- Cleaner, maintainable code.
As SaaS adoption grows, this architecture will help you scale confidently while keeping each tenant’s data safe and consistent.