Lately I’ve been reading the “Unit Testing” book from Vladimir Khorikov and have encountered the following misconception in which some people tend to believe:
In domain-driven design, there’s a guideline saying that you shouldn’t modify more than one aggregate per business operation. […] The guideline is only applicable to systems that work with document databases, though, where each document corresponds to one aggregate.
Let’s explore why this isn’t entirely true and how to address the issue regardless of the type of database you’re using—be it document, relational, or otherwise.
I’ll follow a general pattern to describe the solutions.
Intent
You want to modify two entities within a single business operation.
Problem
Imagine you’re changing a user’s email. Your code includes two classes: User
and Company
.
You also have a rule: the Company
must track a counter representing the number of Users
with a company email domain.
(Anti-)Solution
Wrap the updates for both entities into one transaction in the service layer:
class ChangeUserEmailService {
public constructor() {
private readonly database: Database,
private readonly userRepository: UserRepository,
private readonly companyRepository: CompanyRepository,
}
public async execute(userId: string, companyId: string, newEmail: string) {
const user = await this.userRepository.getByUserId(userId);
const company = await this.companyRepository.getByCompanyId(companyId);
// Passing `company` instance to user to update the company's counter
user.changeEmail(newEmail, company);
await this.database.transaction(connection => {
const userRepository = new UserRepository(connection);
const companyRepository = new CompanyRepository(connection);
// Both `.save()` operations are executed within one transaction
await userRepository.save(user);
await companyRepository.save(company);
})
}
}
Problems
The guideline in DDD that suggests avoiding modification of more than one aggregate per business operation is not limited to document databases. Ignoring this rule introduces strong coupling between two separate aggregates, violating a fundamental principle of tactical DDD patterns.
Solution
Here are two general approaches to solving the problem described above:
1. Merging Aggregates
Combine the User
and Company
into a single aggregate, leaving only one aggregate in the system. This allows you to manage transactions entirely within the CompanyRepository
class. The transaction then becomes an implementation detail of CompanyRepository
.
This approach may not be efficient in this specific case but could work well in other scenarios.
After merging aggregates, the service code would look like this:
const company = await this.companyRepository.getByCompanyId(companyId);
company.changeEmail(newEmail, userId);
await companyRepository.save(company);
Note: You can use
AsyncLocalStorage
in Node.js to manage transactions. However, this doesn’t eliminate the coupling; it merely hides it using platform-specific instrumentation. The coupling remains present at runtime.
2. Creating Eventual Consistency
Split the overloaded business operation into two distinct operations. Use the “change user email” operation to publish domain events (potentially leveraging the outbox pattern). Then, trigger a second operation in response to the UserEmailChanged
event.
The code might look something like this:
// ChangeUserEmailService.ts
const user = await this.userRepository.getByUserId(userId);
user.changeEmail(newEmail);
await userRepository.save(user);
// UpdateCompanyCounter.ts
const company = await this.companyRepository.getByCompanyId(companyId);
company.userEmailChanged(oldEmail, newEmail);
await companyRepository.save(company);
Why It’s Important
Aggregates were designed to protect invariants. Ignoring the core principles of aggregates increases the complexity of already challenging business logic by introducing non-business concerns, such as manual transaction management.
When to Break the Rules
In the real world, sometimes breaking these rules is unavoidable due to changing requirements or the impracticality of implementing a “perfect” solution.
However, such deviations should always be treated as technical debt and addressed later.
Conclusion
Transactions are just an implementation detail. If your service code handles transactions directly, it’s a sign that your layers are improperly designed.
This implementation detail should be abstracted behind repositories to avoid unnecessary coupling.