Encapsulation in DDD

133 Views Asked by At

When designing an aggregate and entities, it would be good to provide the consumer with only the public properties and methods of the aggregate. But it happens that in order to change some property, it is necessary to perform a complex business logic case.

For example, we have a user aggregate and it has an email property. To change the email, you need to make sure that there are no other registered users with the same email. This means that you need to run a complex script with a database search. Embedding the repository (or the repository interface) into the method of the aggregate itself is a bad practice, we get out-of-process dependencies. So you need to put this code in DomainService or AppService (depends on the approach). This means that the property will be internal or public and it can be changed within or outside the assembly. This means that anyone can change it by mistake, bypassing the business rules described in the service.

public class User : IAggregateRoot
{
    public int Id { get; }
    public string Email { get; set; }

    private User(int id, string email)
    {
        Id = id;
        Email = email;
    }
}

public class UserService
{
    private IUserRepository repository;

    public UserService(IUserRepository repository)
    {
        this.repository = repository;
    }

    public async Task ChangeEmail(int userId, string email)
    {
        var exist = await repository.GetByEmailAsync(email);
        if (exist != null)
            throw new DomainException("Email is already in use");

        var user = await repository.GetByIdAsync(userId);
        user.Email = email;
        await repository.SaveAsync(user);
    }
}

It would be nice for the property to be private for modification. Access from other classes would only be through the ChangeEmail method.

public string Email { get; private set; }

This problem could be solved if friendly classes existed in C#, then the service could be given access to private properties and methods of an aggregate or entities.

This problem could also be solved if the assemblies could refer to each other, something like in the picture, but cyclic dependencies are prohibited in C#. (Each square in the drawing is a separate project)

enter image description here

You could try using a nested class that has access to private properties and methods, but a nested class cannot be the same for both the aggregate and the entities associated with the aggregate. For example, Order is an aggregate and OrderItem is an entity. It cannot be nested in two classes at once.

(edited) Of course, you can try to use a similar scheme, but there are a lot of projects and a large number of links between projects. This could be easily simplified if cyclic dependencies were possible.

enter image description here

4

There are 4 best solutions below

2
On

You could use an interface that is only implemented outside the domain (application layer I guess ) and tells you exactly what is needed. This prevents developers from just putting in a string value:

public interface IUniqueEmailAddress 
{
    string Value { get; }
    bool IsUnique { get; }
}

And then have a method in de domain object:

public Email { get; private set; }

public void ChangeEmail( IUniqueEmailAddress email)
{
    if( !email.IsUnique ) throw Exception( 'Uh oh!' );
    Email = email.Value;
    //... other stuff maybe
}

Somewhere in the application you create the email and set it:

var uniqueEmailAddress = new UserService().GetUniqueEmailAddress(email);
var user = repository.FindUser(id);
user.ChangeEmail( uniqueEmailAddress );
6
On

For example, we have a user aggregate and it has an email property. To change the email, you need to make sure that there are no other registered users with the same email. This means that you need to run a complex script with a database search. Embedding the repository (or the repository interface) into the method of the aggregate itself is a bad practice, we get out-of-process dependencies. So you need to put this code in DomainService or AppService (depends on the approach). This means that the property will be internal or public and it can be changed within or outside the assembly.

I think you've gotten yourself a little bit tangled at the end of this.

Let's assume that our goal is to have all of our domain logic in the domain model. In the easy case, we have information that we pass to the domain model, and that model decides what to do.

In the case where we need to worry about email addresses being "unique", that would probably look like passing a collection with all of the email addresses that might conflict with the edit.

So the signature of our method would look something like

public class User : IAggregateRoot
{
    public void ChangeEmail(string email, Collection possibleConflicts)
    {
        if (possibleConflicts.contains(email)) {
            // logic for handling the collision
            throw new DomainException("Email is already in use");
        } else {
            // handle the common case
            this.Email = email;
        }
    }
}

Collection here being some facade in front of an in-memory data structure that can detect the conflict. It might be a general-purpose collection from your library, or it might be a facade with method names taken from your specific domain.

In this sort of design, your application code would be responsible for finding the information to put into the data structure.

    public async Task ChangeEmail(int userId, string email)
    {
        var possibleCollisions = ...;

        var user = await repository.GetByIdAsync(userId);
        user.ChangeEmail(email, possibleCollisions);
        await repository.SaveAsync(user);
    }

That's the ideal - we do all of the I/O things where we do I/O things, and the domain logic only knows about information in local memory.

Or course, in the trivial cases you might not even need the collection, it might be enough to just pass the answer

public class User : IAggregateRoot
{
    public void ChangeEmail(string email, boolean conflict)
    {
        if (conflict) {
            // logic for handling the collision
            throw new DomainException("Email is already in use");
        } else {
            // handle the common case
            this.Email = email;
        }
    }
}

But yes, if you are going to use the User aggregate to detect conflicts, then you are going to need to have some method to access the information it needs somewhere.

And yes, if you expose that capability in a way that people can make mistakes, then you are going to need some counter measures in place (like explaining to people that they shouldn't make mistakes).


That said, uniqueness is a set property, and if uniqueness is an important concern in your domain, then you need set validation.

In other words, your problem may be incorrectly framed because you are overlooking the concept of an EmailDirectory/EmailRegistry in your domain, which is an Aggregate with its own invariant to protect.

    public async Task ChangeUser(int userId, string email)
    {
        var directory = await repository.GetByEmailAsync(email);
        directory.registerUser(userId, email);
        await repository.SaveAsync(directory);
    }

And the enforcement of the correct uniqueness policies would be encapsulated in the directory.

If you are going to be preventing data races, and ensuring that a set is valid in the context of some invariant, then you need to lock the set to prevent concurrent modification.


A last note: for information that comes to you from the world, you need to be really careful about which invariant you choose to enforce. For instance, when Bob tells you that his email address is [email protected], how do you know that's wrong? After all, you aren't the administrator assigning mailboxes in the example.com domain; maybe the information you have is wrong or out of date.

The real world is allowed to change without consulting your machine first.

Often this can mean introducing concepts into your domain model like a distinction between an UnverifiedEmailAddress (this is what the user told us) vs a VerifiedEmailAddress (we executed some verification protocol at such-and-such date, and confirmed that user had control of that email address at the time).

6
On

You could do something like this:

public class User : IAggregateRoot
{
    public int Id { get; }
    public string Email { get; private set; }

    private User(int id, string email)
    {
        Id = id;
        Email = email;
    }

    public void ChangeEmail(string email)
    {
        Email = email;
        //... maybe trigger a domain event here
    }
}

public class UserService
{
    private IUserRepository repository;

    public UserService(IUserRepository repository)
    {
        this.repository = repository;
    }

    public async Task ChangeEmail(int userId, string email)
    {
        var exist = repository.GetByEmailAsync(email);
        if (exist != null)
            throw new DomainException("Email is already in use");

        var user = await repository.GetByIdAsync(userId);
        user.ChangeEmail(email);
        await repository.SaveAsync(user);
    }
}

(edited) But if you want to change the property in the UserService you could do this:

public string Email { get; internal set; }

put this in the Domain csproj:

<ItemGroup>
    <InternalsVisibleTo Include="The name of the project where UserService is" />
</ItemGroup>
4
On

The business rule you are trying to enforce is not at the aggregate root level but at the repository level: you can't handle it in the entity itself, you need to defer that test to the repository. A repository's implementation is in the infrastructure layer but the interface is in the domain layer. This means your entity can reference the repository interface:

public class User : IAggregateRoot
{
  public int Id { get; }
  public string Email { get; private set; }

  private User(int id, string email) => (Id, Email) = (id, email);

  public async Task ChangeEmail(string value, IUserRepository repository)
  {
    if (await repository.EmailExistsAsync(value)) { DomainException.ThrowDuplicateEmail(); }
    Email = value;
  }
}

You might combine this code with some transaction isolation if you fear concurrent acces.