Multitenancy
In a multitenant application, multiple tenants (e.g., customers, organizations) share the same application instance while keeping their data isolated. Multitenancy is a common requirement in SaaS applications and can be implemented in various ways, ranging from soft multitenancy (where data is logically separated) to hard multitenancy (where data is physically separated).
DugongJS provides built-in support for soft multitenancy by allowing a tenant ID to be passed to all adapters during persistence and message broker operations. By default, the built-in adapters handle this at a logical level. However, while not currently provided as a feature, it is also possible to implement custom adapters that enforce harder multitenancy.
Tenant ID Injection
Using the NestJS tutorial as an example, we could associate each bank account with a specific tenant ID. This tenant ID would then be used to tag all domain events, snapshots, consumed messages, outbox messages and could also be used to tag query models.
Tenant ID injection is handled in the application layer. Let's examine the following code snippet from the BankAccountCommandService in the NestJS tutorial:
import { EventSourcingService } from "@dugongjs/nestjs";
import { Injectable } from "@nestjs/common";
import { BankAccount } from "../../domain/bank-account.aggregate.js";
import type { DepositMoneyCommand } from "../../domain/commands/deposit-money.command.js";
import type { OpenAccountCommand } from "../../domain/commands/open-account.command.js";
import type { WithdrawMoneyCommand } from "../../domain/commands/withdraw-money.command.js";
@Injectable()
export class BankAccountCommandService {
constructor(private readonly eventSourcingService: EventSourcingService) {}
public async openAccount(command: OpenAccountCommand): Promise<BankAccount> {
return this.eventSourcingService.transaction(async (transaction) => {
const accountContext = this.eventSourcingService.createAggregateContext(transaction, BankAccount);
const account = new BankAccount();
account.openAccount(command);
await accountContext.applyAndCommitStagedDomainEvents(account);
return account;
});
}
public async depositMoney(command: DepositMoneyCommand): Promise<void> {
return this.eventSourcingService.transaction(async (transaction) => {
const accountContext = this.eventSourcingService.createAggregateContext(transaction, BankAccount);
const account = await accountContext.build(command.accountId);
account.depositMoney(command);
await accountContext.applyAndCommitStagedDomainEvents(account);
});
}
// Other commands methods...
}
We can modify the openAccount and depositMoney methods to accept a tenantId parameter. DugongJS will then automatically associate this tenant ID with all domain events generated during the command execution:
import { EventSourcingService } from "@dugongjs/nestjs";
import { Injectable } from "@nestjs/common";
import { BankAccount } from "../../domain/bank-account.aggregate.js";
import type { DepositMoneyCommand } from "../../domain/commands/deposit-money.command.js";
import type { OpenAccountCommand } from "../../domain/commands/open-account.command.js";
import type { WithdrawMoneyCommand } from "../../domain/commands/withdraw-money.command.js";
@Injectable()
export class BankAccountCommandService {
constructor(private readonly eventSourcingService: EventSourcingService) {}
public async openAccount(tenantId: string, command: OpenAccountCommand): Promise<BankAccount> {
return this.eventSourcingService.transaction(async (transaction) => {
const accountContext = this.eventSourcingService
.createAggregateContext(transaction, BankAccount)
.withTenantId(tenantId);
const account = new BankAccount();
account.openAccount(command);
await accountContext.applyAndCommitStagedDomainEvents(account);
return account;
});
}
public async depositMoney(tenantId: string, command: DepositMoneyCommand): Promise<void> {
return this.eventSourcingService.transaction(async (transaction) => {
const accountContext = this.eventSourcingService
.createAggregateContext(transaction, BankAccount)
.withTenantId(tenantId);
const account = await accountContext.build(command.accountId);
account.depositMoney(command);
await accountContext.applyAndCommitStagedDomainEvents(account);
});
}
// Other commands methods...
}
These changes lead to two important outcomes:
- All domain events and snapshots generated during the execution of the
openAccountanddepositMoneycommands will have the specified tenant ID associated with them. - When retrieving aggregate instances using the
accountContext.getByIdmethod, only events and snapshots associated with the specified tenant ID will be considered. If the aggregate instance does not exist for that tenant, an error will be thrown.
Note that there is no built-in mechanism for extracting the tenant ID from incoming requests. It is up to the controller layer (or middlewares, guards, interceptors, etc.) to extract the tenant ID from the request context and pass it down to the application layer. This can be done using various strategies, such as extracting the tenant ID from request headers, JWT tokens, or session data.
In the NestJS ecosystem, NestJS CLS is a popular choice to handle tenant propagation.
Multitenancy Control Mechanisms
By default, tenant scoping is declared at every call site where the AggregateContext is created. This default is permissive and makes it possible to create an operation that incorrectly writes to the "no tenant" scope by accident by forgetting to call withTenantId. Without a control mechanism in place, adding tenant IDs is a developer convention, rather than something that can be centrally enforced.
DugongJS provides two mechanisms to enable multitenancy control. Both mechanisms can be enabled simultaneously for maximum assurance.
The control is enforced by the following methods:
AggregateFactory.buildAggregateFactory.buildFromEventLogAggregateFactory.buildFromLatestSnapshotAggregateManager.commitStagedDomainEventsAggregateManager.applyAndCommitStagedDomainEvents
Global Tenant Scope Control
The first mechanism is the requireTenantScope property on the DugongModule (for the NestJS integration):
@Module({
imports: [
DugongModule.forRoot({
currentOrigin: "BankingContext-AccountService",
requireTenantScope: true,
adapters: new DugongAdapterBuilder()
.register(loggerAdapter)
.register(repositoryTypeOrmAdapter)
.register(transactionManagerTypeOrmAdapter)
.build()
})
]
})
export class AppModule {}
When enabled, calling one of these methods without declaring the tenant scope throws TenantScopeNotSetError. A tenant scope is declared either by calling withTenantId or by explicitly declaring withoutTenantId:
const accountContext = this.eventSourcingService
.createAggregateContext(transaction, BankAccount)
.withoutTenantId();
At runtime this behaves exactly like leaving the tenant unset, but it records it as a deliberate decision and doesn't throw.
Per Aggregate Tenant Scope Control
The global tenant scope control only enforces that the tenant scope decision is deliberate for the specific call site. In practice, an aggregate is often entirely tenant scoped or entirely unscoped. In some cases, it could make sense to allow both options.
The @TenantScoped decorator provides a second control mechanism that can be applied to an aggregate to make a decision about its tenant scope. The decorator has three modes:
@TenantScoped.Always()— the aggregate only exists within a tenant. Operating without a tenant ID throws anAggregateRequiresTenantScopeError.@TenantScoped.Never()— the aggregate is shared across all tenants. CallingwithTenantIdthrows anAggregateForbidsTenantScopeError.@TenantScoped.Optional()— either scope is valid, but one must be chosen. Leaving it unset throws aTenantScopeNotSetError.
The decorator is applied to the aggregate class:
@Aggregate("BankAccount")
@TenantScoped.Always()
export class BankAccount extends AbstractAggregateRoot {
// Rest of the aggregate...
}
The TenantScoped decorator is imported from @dugongjs/core.
How Both Mechanisms Interact
An aggregate's own declaration always takes precedence over the application-wide flag, which only governs aggregates that have no declaration of their own. The following table shows the different permutations, where:
- ✅ - allowed
- ❌ - an error is thrown
| Aggregate decorator | requireTenantScope | Not declared | withTenantId | withoutTenantId |
|---|---|---|---|---|
@TenantScoped.Always() | N/A | ❌ | ✅ | ❌ |
@TenantScoped.Never() | N/A | ✅ | ❌ | ✅ |
@TenantScoped.Optional() | N/A | ❌ | ✅ | ✅ |
| None | false | ✅ | ✅ | ✅ |
| None | true | ❌ | ✅ | ✅ |
Note that with @TenantScoped.Never(), you do not need to repeatedly pass withoutTenantId, even when requireTenantScope is true.