1

Let's say my domain has a User Entity that has a many to many relationship with itself representing "Friends"

public class User
{
    public int Id;
    public IEnumerable<User>? Friends;
}

In my application layer, I have a service that makes 2 User friend together and the only requirement is that they are not already friends:

public class UserServies
{
    public void AddFriend(int user1Id, int user2Id)
    {
        var user1 = _repository.GetUserById(user1Id);
        var user2 = _repository.GetUserById(user2Id);
        // use user1 and user2 in my domain layer to enforce business rules... 
    }
}

GetUserById would execute a SQL query like this:

SELECT u.* FROM Users u
LEFT JOIN Users_Friends uf ON u.Id = uf.UserId
LEFT JOIN Users f ON f.Id = uf.FriendId
WHERE u.Id = @Id

So far so good! But let's say the requirements changed and I would now need to access user1.Friends[i].Friends, this would result in a runtime error since we didn't load that data. I'm having trouble designing the domain layer in a way to avoid these kind of problems. I've thought of a few solutions, but none that makes me happy:

Eagerly load all relationships all the time

Bad: Performance wise it's terrible as it would require me to load all users all the time.

Only load the necessary data required by my domain business rules

Bad: This require way too much coupling between the data loader and the domain rules using said data. It would also be incredibly easy for a business rules change to result in a runtime error because someone didn't realize that the data loaded was no longer adequate (such as in my example above).

Use lazy loading in domain layer

Bad: Now the domain layer is no longer pure and is coupled to whatever persistence mechanism the infrastructure is using.

Use specific DAO object for each business operations

In this solution instead of dealing with a User, AddFriend would be dealing with a AddFriendUserDAO. AddFriendUserDAO would also need to have all its relationships be specific AddFriend DAO for it to works. Needless to say, it would increase the amount of code I need to write exponentially.

Do you have any recommended design pattern to deal with this scenario? I feel like it should be a pretty common scenario but I just don't know the right words to search for the solution.

Thanks.

9
  • 2
    Just to point out: the added difficulty here is that you have a secret link between your different domain aggregates. If you load Tom as a person (which includes their friend list), and you also load Andy (which includes their friend list), and Tom now adds Andy as a friend, that means that you're expecting Andy's list to update and suddenly also contain Tom. This is not something you will find prescribed in general DDD documentation as it is a very special case. I haven't had the time to investigate for a full answer but I suspect that you will need to make friendships have their own context.
    – Flater
    Sep 8, 2023 at 3:02
  • Well it's a made up situation, I mostly tried to come up with the simplest example. The question isn't about this specific scenario but more about how to deal with the fact that objects will always be partially loaded and the domain layer needs to be in sync with the infrastructure layer in order to avoid error Sep 8, 2023 at 4:01
  • 1
    The point I was getting at is that this is a problem of your own making, this is a nested structure that is very likely an infinite one (to the point of exhaustion of having loaded all your users). You don't want this, but you also don't want any of the additional work that the alternatives present. Something's gotta give, and we can't decide which poison is best to pick. There are myriad ways of modeling this and I'm not convinced that the current question is focused enough towards a particular approach. Every approach has wildly different pros and cons and this requires more analysis.
    – Flater
    Sep 8, 2023 at 5:22
  • 2
    What I have found that helps is not holding the whole objects, just the ids. Ask yourself why you actually need to describe your domain entity with all the friends' objects. Where exactly does your BL use it? is it common or do you only use it occasionally? Your User entity has friends, ok, but what if the entity itself will hold only the ids of the friends?
    – oren
    Sep 8, 2023 at 10:25
  • 1
    I almost exclusively use the domain layer for write operations, and yes I'm already implementing the CQRS pattern but I don't see how that is relevant to how I design my domain layer? Sep 12, 2023 at 17:40

2 Answers 2

2

The general approach here is going to be to model the interaction taking place. That is, make the implicit explicit. Let us add an additional concept to this system, FriendRequest, to carry some of that knowledge we are trying to crunch.

Here is an example of the general approach to use. I've extended our invariants to include an optional expiry and a max retry count to further exemplify how this approach can easily be extended to capture other constraints (and because "does something already exist" often doesn't feel like the kind of constraint that needs to be modeled explicitly as it is easily derived):

public class FriendRequest 
{
    public int senderId;
    public int receiverId;
    public int retryCount;
    public int? sentAt;
    public int? expiresAt;
    public int? acceptedAt;

    public void send(int? ttl) 
    {
        if (this.acceptedAt != null) throw new AlreadyFriends();
        if (this.retryCount + 1 > MAX_RETRY_COUNT) throw new GiveUpAlready();     

        this.sentAt = ...
        this.expiresAt = ...
        this.retryCount = ...
    }

    public void accept() 
    {
        if (this.sentAt == null) throw new NeverSent();
        if (this.isExpired) throw new Expired();
        if (this.acceptedAt != null) throw new AlreadyFriends();

        this.acceptedAt = ...
    }
}

public class UserService
{
    private FriendRequestRepository friends; //.find will return an "unsent" FriendRequest provided both users exist

    public void sendFriendRequest(int senderId, int receiverId)
    {
        var req = friends.find(senderId, receiverId); // may throw FriendRequestNotFound/UserNotFound
        req.send(FRIEND_REQUEST_TTL);
        friends.save(req);
    }

    public void acceptFriendRequest(int senderId, int receiverId)
    {
        var req = friends.find(senderId, receiverId); // may throw FriendRequestNotFound/UserNotFound
        req.accept(); 
        friends.save(req);
    }
}

There are other, similar, ways to organize the logic but the above should get the gears turning. Notice we didn't need to load any lists of friends! (and also arrived at a very terse, simple representation of our domain/rules)

The key concept here is to convert our rules into data (in this case our retryCount/sentAt/acceptedAt/expiresAt) that can then be used by our domain. This process is extensible to nearly any rule you can dream up. You want to check friends multiple layers deep? Project a isIndirectlyFriendsAlready field to our FriendRequest.

By representing our invariant with data we can then separate how/where we produce that data from how/where it is used. How could we add an expiry or retry counts to AcceptFriendRequest(User user1, User user2, List<User> user1Friends, List<User> user2Friends)? Are you going to enforce the same thing in SendFriendRequest? How? It becomes a mess.

8
  • That doesn't answer my question and does not help with business rules like "to add a friend you need to have less than 2 separations of friendship". In general adding your business rules as database constraint ONLY is really not recommended, your business / domain layer SHOULD also be clear about the requirements. Sep 12, 2023 at 17:45
  • @MyUsername112358 The answer above is "the recommended design pattern to deal with this scenario", and provides a solution to the problem in accordance to the constraint you provided (though I added an additional constraint to exemplify how the solution may be extended). Do you want me to update my answer to handle your new constraint as well? More generally, do you think it's possible (or advisable) to model this system such that it can easily handle any arbitrary constraint? And to your last point, your repository interface is part of your domain. Time to be pragmatic. Sep 12, 2023 at 19:39
  • The repository interface CAN BE part of the domain (does not have to be), but not the implementation. Hence business rules should absolutely not be found in your repository. Your solution is not pragmatic, it is simply wrong. @oren suggestion in the comment is actually pragmatic and fits my requirements Sep 12, 2023 at 21:20
  • @MyUsername112358 The suggestion to only store lists of ids does not solve this problem in a general way. At a small enough scale with the right access patterns, sure, but I meant to provide a general answer to "how can I model a set constraint without loading my entire database". The answer, unfortunately, is that DDD doesn't play well with set theory, and you will have to make a tradeoff. You plan to lazy load then? FWIW it's trivial to change this design to keep the rule in the domain (still without loading friend lists), but I'll leave that as an exercise for the reader! Sep 13, 2023 at 1:45
  • @MyUsername112358 At the other commenter's request, I've edited my answer to show how we can keep the rule in our domain (and still be optimally efficient). Sep 13, 2023 at 17:08
0

Somewhat similar to @user3347715's answer, I would design a FriendRelationship aggregate root that contains exactly the data perimeter needed to enforce your invariant. So, load user IDs remote from user1 by up to 2 levels of friendship and check that user2 is not in there.

Note that it is more of a DDD purist approach than @user3347715's solution because the invariant logic will be in the domain layer instead of repository implementation or SQL query. But their answer might well be the more pragmatic one performance wise, depending on the amount of friend data to load at every friend request.

Another approach would be to make the operation eventually consistent with a deduplication background job that boils down direct+indirect friend relations to just an indirect one if the direct one is more recent.

This also questions the notion of friending in the context of your domain. Why do we feel the need to look past the first level of friends? Does it mean a level 2 friend is just as good as a direct friend, for some or all purposes? Could we then store a map of [direct or indirect] friends and get rid of the invariant, making the friending operation idempotent?

So, no silver bullet, only situational answers depending on the business domain, as often.

2
  • 1
    You needn't load the data perimeter to enforce the invariant. In general the approach is to defunctionalize the process by introducing additional data structures to help mediate the flow. So now we have a new invariant needs to look 2 layers of friends deep? Ok. How can we turn that into data? Simple. Include a new projected field into our FriendRequest, say isIndirectlyFriendsAlready, that contains the result from the calculation required to enforce the invariant. In this way our domain can still be responsible for enforcing the rule without being responsible for crunching the data. Sep 13, 2023 at 14:33
  • Yes, thanks, that's another interesting possibility! Especially if you don't mind that isIndirectlyFriends logic is implemented somewhere else than in the domain layer. Can you add that to your answer? Might be what was missing to make OP consider it. Sep 13, 2023 at 14:47

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.