Models abstract and simplify reality to make it understandable and computable. By doing so, certain nuances and details are inevitably omitted. Domain models are no exception. The last time we ended up with the following design of the UserProfile
class:
public class UserContact
{
public EmailAddress Email { get; protected set; } // protected for EF
public Phone Phone { get; protected set; } // protected for EF
protected UserContact(){} // protected for EF
public static UserContact Create(EmailAddress email) =>
new() { Email = email };
public static UserContact Create(Phone phone) =>
new() { Phone = phone };
public static UserContact Create(
EmailAddress email, Phone phone) =>
new() { Email = email, Phone = phone };
}
What if we needed to change the email of the user?
public class UserContact
{
// ...
public void ChangeEmail(EmailAddress newEmail)
{
Email = newEmail;
}
}
Validation typically takes two forms:
-
Contextless: Ensures the self-consistency of an object, for example
EmailAddress
constructor guards implemented one way or another. - Contextual: Verifies an object concerning external factors, like ensuring user email uniqueness across a system.
// UserController
public Task<IActionResult> ChangeEmail(int userId, EmailAddress newEmail)
{
// Contextual validation
User existingUser = await _userRepository.GetByEmailAsync(newEmail);
if (existingUser != null && existingUser.Id != userId)
{
return BadRequest();
}
User user = await _userRepository.GetByIdAsync(userId);
user.ChangeEmail(newEmail);
await _userRepository.SaveAsync(user);
return Ok();
}
DDD Trilemma: A Crucial Tradeoff in Design
In his thought-provoking analysis, Vladimir Khorikov introduces the concept of the DDD Trilemma, highlighting a pivotal choice developers must make:
- Preserve domain model completeness and purity at a potential cost to performance.
- Maintain performance and model completeness while sacrificing purity.
- Uphold performance and model purity but compromise on completeness.
Making Decisions Based on Queries
Coincidentally, Scott Wlaschin in his book “Domain Modeling Made Functional” (which I strongly recommend even for those who are not familiar with F#) recommends keeping the pure functions intact, but placing them between impure I/O functions if pushing I/O towards application boundaries is not an option because of performance reasons.
Simplifying Decisions: Two Robust Rules
In C#, I found it useful to domain Domain Services should the need arise:
-
Maintain Purity: use constructors,
required
keyword accompanied by Value Objects for the “pure” (IO-less) parts of your model. -
Domain logic fragmentation is a lesser evil: use domain services for I/O operations, preserving the absence of IO in your entity classes. Introduce a domain service anytime you need the
async/await
. Don't createasync
methods in your entity classes. Don't use synchronous IO because it obscures the impurity.
These rules make it so it's easy to choose between an entity method and a service. However, there is one notable exception of the infamous Aggregate pattern, which I'd like to discuss next time.
Top comments (0)