Sitemap
iamprovidence

iamprovidence

👨🏼‍💻 Full Stack Dev writing about software architecture, patterns and other programming stuff https://www.buymeacoffee.com/iamprovidence

DDD Trilemma

5 min readFeb 7, 2025

--

Press enter or click to view image in full size

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:

Press enter or click to view image in full size

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 ChangeEmail method ensures that all rules related to email changes (validation and uniqueness) are centralized within the User class.
  • 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 users lists.
  • Ensures the User object 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 ChangeEmail works 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 ChangeEmail method 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 UserRepository can 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:
    - the User entity focuses solely on encapsulating its state and enforcing its own internal rules (e.g., email format validation).
    - the UserDomainService handles external interactions, such as checking for email uniqueness and updating the entity in the repository, keeping the domain model clean and focused.
  • The UserDomainService can 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.

Press enter or click to view image in full size

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 😉

--

--

iamprovidence
iamprovidence