Geek Culture

A new tech publication by Start it up (https://medium.com/swlh).

How to respect an invariant implicating two aggregate roots

--

(Une fois de plus, merci à Jason Tan pour son peer review!)

When trying to apply strict DDD rules, you often face the DDD Trilemma (https://enterprisecraftsmanship.com/posts/domain-model-purity-completeness/). Many approaches exist to address that problem. I have probably used them all over the years. But recently, I tried something new by myself, by combining other patterns together to solve that problem. And this is the approach I like the most.

First, a brief description of the problem. Consider that you have a Subscription aggregate root:

Also consider that you have the Customer aggregate root:

Just a reminder of a critical DDD rule, aggregate roots should reference other aggregate roots by their Id only, not by a direct reference to them.

And now, you need to implement this invariant: A customer cannot have more than one subscription for a specific Sku. How can you do that?

A typical and perhaps naive approach is to implement it in a domain service like this pseudo code:

1- Load the Customer from a CustomerRepository

2- Load all the Subscriptions from a SubscriptionRepository, using all the SubscriptionIds from the loaded Customer

3- Check if the new Sku is already in one of the loaded Subscriptions

The problem with this approach is nothing enforces the application of the invariant. Anybody is free to instantiate a new Subscription and add the SubscriptionId to the list of the Customer. When nothing is enforced, people forget to apply invariants, or they are just not aware of the invariants and just bypass them by mistake. If you want an always-valid domain model, you must enforce invariant applicability.

Some people are fans of injecting repositories inside aggregate roots when they face similar problems, but this breaks the domain purity (explained in the DDD trilemma). This method, named Disconnected Domain Model, can also open the door to update multiple aggregate roots in a single transaction (more on that in Chapter 7 and 10 of Vaughn Vernon’s famous book). Therefore, I try to avoid that solution as much as possible.

When this kind of problem arises, is it often a sign that a concept is missing. So here is the way I like to solve this kind of problem.

So how does it work?

I introduced the CustomerSubscriptions aggregate root, which represents the concept between a Customer and its Subscriptions. In my own opinion, there is no need to have a dedicated data representation for that in the database. It can be built as a read model on top of the existing data from Subscription and Customer tables to build the list of CustomerSubscription value objects. This is controversial and some people do not like having multiple read models on the same table because you can theoretically bypass some invariants that are present only in one aggregate and not the other. It is true. Personally, by never implementing a Save/Update method on the CustomerSubscriptionsRepository, it is a good compromise compared to having a dedicated data representation in the database, and synchronizing that data, without having to break the “one aggregate per transaction” rule. It can be done that way too, but it is much more complicated with no real benefits. Keeping the complexity as low of possible is always a good rule of thumb, when the circumstances allow it. Personal preference here.

The next piece of the puzzle is the ISubscriptionCreationData interface. You absolutely need to have an instance of that interface to be able to instantiate a Subscription because this is required in the Subscription constructor. And the only implementation of that interface resides inside the CustomerSubscriptions as a private class, meaning nobody can instantiate that class except the CustomerSubscriptions class (C#). So, you must go through that CustomerSubscriptions aggregate root to build the creation data, and the BuildSubscriptionCreationData method of that class is responsible to apply the challenging invariant. In other words, the purpose of CustomerSubscriptions is to act as a mandatory factory entry point to have access to the data required to instantiate a Subscription. You just cannot forget it or bypass it.

Of course, this solution is not perfect. Anybody could decide to implement its own implementation of the ISubscriptionCreationData interface. But by doing that, you KNOW you are doing something wrong. Compared to needing to remember to use a domain service, this is a far better and less error-prone approach. The goal is not to protect the code from people wanting to hack it (they will always be able to…) but to protect people from themselves, from making a mistake.

Database-first people sometime try to solve that problem by adding constraints in the database schema. Remember that can leads to an anemic domain model, and it is not recommended. Database persistence is merely an implementation detail. Not a way to model business rules properly. Nothing is wrong with using that solution on top of the invariant properly modeled in the domain, but you should not rely on it.

Hope you like that solution. Always available and happy to debate about code implementation!

--

--

Normand Bédard
Normand Bédard

Written by Normand Bédard

French Canadian senior software developer for SherWeb since 2010. Ultramarathon, drones and camping enthusiast!

Responses (1)