DDD Trilemma
Pick two: deliver a project quickly, make it high-quality, or keep it cheap. Does this trilemma sound familiar? As software engineers, we always have to choose two properties and sacrifice the third one.
There are many examples of this trade-off principle:
- in the CAP theorem, you choose between consistency, availability, and partition tolerance
- in cloud computing, we have scalability, performance, and cost
- for code design, its flexibility, simplicity, and robustness
The same applies to Domain-Driven Design (DDD). The DDD Trilemma refers to the inherent trade-offs in Domain-Driven Design when balancing three critical aspects of software design: purity, encapsulation, and performance:
- purity: means that an entity does not have dependencies on external services
- encapsulation: allows you to centralize all the domain logic in a single place
- performance: addresses the need for pragmatic and efficient implementation
Let’s say we have the next requirement in our domain:
This problem can be solved in multiple ways.
Purity + Encapsulation
In case you go with this option, you would have to sacrifice the performance:
class User
{
public int Id { get; }
public string Email { get; }
public void ChangeEmail(string email, IReadOnlyList<User> users)
{
var isValid = email.Contains("@");
if (!isValid) throw new InvalidArgumentException();
var isAlreadyInUse = users.Any(u => u.Email == email);
if (isAlreadyInUse) throw new DomainException();
Email = email;
}
}Purity and encapsulation introduce multiple benefits:
- The
ChangeEmailmethod ensures that all rules related to email changes (validation and uniqueness) are centralized within theUserclass. - This prevents scattering business logic across the application, making it easier to maintain and reuse.
- Since the logic resides within the domain object, testing email changes can be done in isolation by providing mock or real
userslists. - Ensures the
Userobject is always in a valid state by enforcing constraints internally (e.g., valid email format, uniqueness). - The logic is self-contained, making it easier for developers to understand how
ChangeEmailworks without navigating through multiple layers of code.
Sounds great, doesn't it? Buuut… performance 😭.
- Checking email uniqueness in memory (
List<User>) doesn't scale well in applications with large datasets.
Encapsulation + Performance
We can improve performance by introducing UserRepository dependency in our model:
class User
{
public int Id { get; }
public string Email { get; }
public void ChangeEmail(string email, IUserRepository userRepository)
{
var isValid = email.Contains("@");
if (!isValid) throw new InvalidArgumentException();
var isAlreadyInUse = userRepository.IsEmailUsed(email);
if (isAlreadyInUse) throw new DomainException();
Email = email;
}
}We still have encapsulation, which means:
- The
ChangeEmailmethod encapsulates all rules related to email changes (validation and uniqueness), ensuring the domain object remains the central authority for its behavior.
Abstractions make the code agile:
- The repository abstraction (
IUserRepository) allows the domain logic to remain agnostic of the underlying data source. This enables easier switching between different persistence mechanisms (e.g., SQL, NoSQL, in-memory).
We also gain performance improvements:
- By delegating the email uniqueness check to the
IUserRepository, which can query the database efficiently
The code is still testable:
- The
UserRepositorycan be mocked during testing, allowing the method's behavior to be tested independently of the actual data source.
However, injecting IUserRepository directly into the domain model couples the Domain layer to the Infrastructure layer. This is a known DI anti-pattern called method injection.
Get iamprovidence’s stories in your inbox
Join Medium for free to get updates from this writer.
Therefore some developers may use the predicate here:
class User
{
. . .
public void ChangeEmail(string email, Predicate<string> isEmailUsed)
{
var isValid = email.Contains("@");
if (!isValid) throw new InvalidArgumentException();
if (isEmailUsed(email)) throw new DomainException();
Email = email;
}
}
. . .
user.ChangeEmail("john.doe@mail.com", (email) => userRepository.IsEmailUsed(email));Buuuut… is this better? The entity still is not pure. How big of a deal it can be🤔? For a small example like here, it is a wee. However, in real applications, when code tends to grow in size bloated domain model can quickly lead to problems.
If you allow a model to depend on a repository, you may want to prevent concurrent requests and inject services responsible for pessimistic locking. Then you will introduce a cache here. And so on. So when do you stop?
This increases the complexity of the domain model, blurs the responsibilities, and creates a God’s object.
Purity + Performance
The last option would be to give up on encapsulation.
class User
{
public int Id { get; }
public string Email { get; }
public void ChangeEmail(string email)
{
var isValid = email.Contains("@");
if (!isValid) throw new InvalidArgumentException();
Email = email;
}
}
class UserDomainService
{
public void ChangeUserEmail(int userId, string newEmail)
{
var isAlreadyInUse = _userRepository.IsEmailUsed(newEmail);
if (isAlreadyInUse) throw new DomainException();
var user = _userRepository.Get(userId);
user.ChangeEmail(newEmail);
_userRepository.Update(user);
}
}A less cohesive domain model can potentially lead to bugs, since developers may simply not know about the service; miss the business invariant, and so on. The business logic is scattered across the codebase. It increases the complexity, adds the number of classes, etc.
Regardless of the drawbacks, you have:
- a better separation of concerns:
- theUserentity focuses solely on encapsulating its state and enforcing its own internal rules (e.g., email format validation).
- theUserDomainServicehandles external interactions, such as checking for email uniqueness and updating the entity in the repository, keeping the domain model clean and focused. - The
UserDomainServicecan improve performance by calling sufficient repository’s queries - By decoupling external data dependencies from the domain model, the design supports greater flexibility in adapting to changes in the persistence mechanism or business rules.
Conclusion
Having said that, which approach do you choose? My answer is … I don’t know 🤷♂ . What? Do not expect me to have answers to all the questions 😅.
Each of the three approaches has its advantages and disadvantages. In practice, you won’t adhere to just one but rather choose the one that fits best in your specific use case.
However, I’d suggest something quite unexpected: prioritize purity.
DDD is about focusing on and isolating your domain, not reducing latency.
Check this out: if you need to implement a business use case, you load an entity, update it (accounting business invariants), and then save it back. If performance were the priority, you’d directly update the record in the database.
Developers usually avoid that, since the absence of clear separation quickly leads to spaghetti code. While clean code tends to be slower, it is also easier to maintain. Managing code complexity is the primary purpose of these paradigms, design patterns, and architectural styles.
That’s why I prefer simple aggregates, and when the algorithm becomes more complex or requires computation, I move it to a service. After all, that’s the whole point of having a service 😅.
Let me know what approach you prefer 💬 Share the article 🔄 Clap 👏 Support ☕️ And follow ✅ You know the order 😉








