Domain Driven Design Is Hard But... - Part 3
1. Recap
In part 2, I have introduced the Aggregate, Factory and Repository. Aggregate is a mechanism to group closely related objects so as to reduce the complexity of association between objects and to ease the process of management of object life spans. Factory provides a way to constitute an aggregate by encapsulating the details within the aggregates. Repository offloads the implementation of persistence logic from the objects to handle the storage and reconstitution of aggregates. Lastly, factory is often used together with Repository for reconstitution of aggregates.
2. Introduction
In this part, since I have already touched on the basis of the building blocks of DDD, I would like to share a few important concepts to keep in mind as the project goes further. These concepts are Side Effect Free Functions, Intention Revealing Interfaces, Concept Contours, Bounded Context and Context Map.
Also, in lights of big enterprise projects, the same domain terms might have different meanings in different departments. For example, in universities, in the context of courses the teacher means someone who is capable of giving lectures while in the context of the finance system the teacher means a salaried employee who can draw a monthly salary and is entitled to various benefits. Though the domain terms - teacher share the same literal name, it might be quite different concepts in different contexts. Eric Evans discussed the Bounded Context to draw the clear line between different contexts. Furthermore, it is often common to collaborate with different contexts/teams in a big enterprise project. Taking Airbnb for example, it would have a route planning team, a payment team, a notification team, etc.. How do these teams work together? Context Map is proposed to tackle how to handle different types of relationship between teams.
3. Side Effect Free Functions
Side Effect in software means the aftereffect of an action results in a change of the system state. In an application, there are two types of methods:
- Side effect free methods - functions
- Side effect methods - commands
With regards to side effect free function, it ensures idempotence - it can be called repetitively and guarantee to produce the same result.
However, for commands, since every time it is called and the system state will get updated, the effect of the commands is causing changes based on the previous state of the system. That is to imply it is harder to predict the behaviour and also hard to test it.
Now let us look at one example of calculating the GPA of a student's term exam results.
public void completeTerm(Student student, Map<String, Integer> results) {
ActiveModules activeModules = student.getActiveModules();
Integer sumOfWeightedResults = activeModules.getModules()
.stream()
.reduce(0,
(subSum, module) -> subSum + results.get(module.getModuleName()) * module.getPoints(),
Integer::sum);
Integer sumOfPoints = activeModules.getModules()
.stream()
.reduce(0,
(subPoints, module) -> subPoints + module.getPoints(),
Integer::sum);
double gpa = ((double) sumOfWeightedResults) / sumOfPoints;
studentRepository.updateGPA(gpa, student.getStudentId().getId());
}
This is a command (side effect method) as it updates the student's GPA. If we would like to unit test this piece of code, it is a little bit hard as we might have to mock the studentRepository and verify the output. The useful business logic here is the calculation of GPA. We could refactor a little bit here to extract side effect free function:
public void completeTerm(Student student, Map<String, Integer> results) {
ActiveModules activeModules = student.getActiveModules();
double gpa = calculateGpa(results, activeModules.getModules());
studentRepository.updateGPA(gpa, student.getStudentId().getId());
}
public double calculateGpa(Map<String, Integer> results, List<Module> modules) {
Integer sumOfWeightedResults = modules.stream()
.reduce(0,
(subSum, module) -> subSum + results.get(module.getModuleName()) * module.getPoints(),
Integer::sum);
Integer sumOfPoints = modules.stream()
.reduce(0,
(subPoints, module) -> subPoints + module.getPoints(),
Integer::sum);
return ((double) sumOfWeightedResults) / sumOfPoints;
}
The calculateGpa function now is a side effect free function and it would be very easy to test this function as it is stateless and predictable. This might seems quite simple to just extract a function like that; however, imagine if you would have to calculate the accumulated GPA which is based on the previous terms' GPA and the calculation is stateful now, we can still make use the of the side effect free function to wrap the important business logic:
private double calculateAccumulatedGpa(double previousGpa, Map<String, Integer> results, List<Module> modules);
4. Intention Revealing Interfaces
If a developer must understand the implementation of an interface in order to know what it does and hence use it, the value of the encapsulation is lost. As mentioned in part I, the team agrees on the Ubiquitous Language and this can be revealed by the names of interface, classes and methods.
Following is an example that I described in Part 2 for different Module Policies, the names of the detailed policy class implies the underlying business logic.
public interface ModulePolicy
public class MaximumModulePerTerm implements ModulePolicy
public class MaximumModulePerDegree implements ModulePolicy
public class MajorQualifiedByCoreModules implements ModulePolicy
By looking at the name of the classes, it would be quite easy to understand what the intentions of these policies are. A change in the ubiquitous language will result in a renaming refactoring in the domain models.
5. Concept Contours
In real case, it would be hard to know all the business concepts in the knowledge crunching phrase due the reasons that:
- Communication is expensive, even though knowledge crunching is practised to eliminate the misunderstandings, developers might still interpret some concepts differently from the domain experts.
- Since requirements are evolving all the time, some business concepts aren't in a concrete state to be discussed in the early beginning. As the project goes further, the contours of these concepts start to evolve.
- There are some intermediate business concepts that domain experts tend to silently navigate through automatically and only communicate the end concept to developers; however, these concepts are actually of importance in the domain models.
These are just some reasons that I experienced before or can think of. There are definitely more reasons why some of the important business logics are not realized. How can we then revive these concepts then? Eric Evans states that, "through relentless refactoring" and continuous knowledge crunching.
Refactoring and Automatic Test Suits always go together. Automatic test suits will help developers ensure that any refactoring they do will not break the system. When you start to find out that some new features are hard to add or a certain way of implementing is hacky, you might start to wonder if you have missed some intermediate concepts. It will be a good time to:
- Initiate a knowledge crunching session with domain experts to go through your struggles. Only when you bluntly ask about it, domain experts will explicitly bring out those “hidden” concepts.
- Start refactoring. Scan through the codebase, do you find any hidden repetitive logic that are scattered in different places?
The breakthrough might happen and it might not, but refactoring is helpful in uncovering the new concept contours.
6. Bounded Context
Before we get into the formal definition of bounded context, I would like to share one personal story for one project that I have worked on before.
In that project, everyone was in some ways forced to abide by one single giant domain model and that is maintained by one core team. There is this one domain model - customer - that almost all the different teams would have realized as a core model. The struggle was that since everyone is adding different properties to that model, one time I misused one boolean attribute from another team. The name of the attribute suggests the flag I need to enable a feature. Everything was fine and in the test environment and later we found out that boolean property meant something else in a different context though the concept is very similar.
How to avoid such problems is what Bounded Context is trying to resolve.
By adapting the domain models to different contexts or rather bounded contexts, teams are free from polluting each other's models or crashing on similar concepts. However, there are also cases where different contexts need to be shared on certain parts of a model. Shared Kernel is introduced to facilitate this requirement. The simple diagram about the university system below demonstrates how two bounded contexts and a shared kernel work in different sub domains / contexts.
We could look at how some code that kind of represents this terminology.
package org.xudong.module;
public class ModuleBase implements Entity<ModuleCode> {
private String moduleName;
private String description;
private List<String> offeringTerms;
private ModuleCode moduleCode;
public ModuleBase(builder br) {
this.moduleName = br.moduleName;
this.description = br.description;
this.offeringTerms = br.offeringTerms;
this.moduleCode = br.moduleCode;
}
public static class builder {
private String moduleName;
private String description;
private List<String> offeringTerms = Collections.emptyList();
private final ModuleCode moduleCode;
public builder(ModuleCode moduleCode) {
this.moduleCode = moduleCode;
}
public builder withModuleName(String moduleName) {
this.moduleName = moduleName;
return this;
}
public builder withDescription(String description) {
this.description = description;
return this;
}
public builder withOfferingTerm(List<String> offeringTerms) {
this.offeringTerms = offeringTerms;
return this;
}
public ModuleBase build() {
return new ModuleBase(this);
}
}
}
package org.xudong.student;
public class Module implements ValueObject {
private final ModuleBase moduleBasic;
private final String grade;
public Module(String moduleName, ModuleBase moduleBasic, String grade) {
this.moduleBasic = moduleBasic;
this.grade = grade;
}
}
package org.xudong.teacher;
public class Module implements ValueObject {
private final ModuleBase moduleBase;
private final Map<StudentId, Double> grades;
private final List<String> feedbacks;
public Module(ModuleBase moduleBase) {
this.moduleBase = moduleBase;
this.grades = Collections.emptyMap();
this.feedbacks = Collections.emptyList();
}
}
Notice that the Student and Teacher are two different bounded contexts; however they are sharing the ModuleBase which is managed by another team for Module. When there is a change in the ModuleBase, the information such as module code, module name, module description will automatically be synced to Student and Teacher respectively.
You must be wondering other than the Shared Kernel, will there be other relationships between different Bounded Contexts. Let’s look at this in the next section - Context Map.
7. Context Map
Integration between different contexts must go through a translation. How the translation is handled depends on the team dynamics in bigger enterprise projects where different bounded contexts are handled by different teams. Shared Kernel has been discussed in point 6 and it is one of the relationships between different teams residing in different bounded contexts. The following are a few other common relationships.
7.1 Customer/Supplier Development Teams
Oftentimes a subsystem feeds into another subsystem. In big projects, it is often that one downstream subsystem depends on another upstream subsystem. Things can go wrong if the relationship between the two teams is not defined clearly. For example, the upstream team might worry about breaking the downstream subsystem in rolling out new features and sometimes the downstream team is crying for a new feature from the upstream system to unblock their development process.
In this kind of relationship, it is suggested that:
- Upstream team would invite the downstream team for their interaction planning phase and treat the representatives from the downstream team as customers to gather requirements.
- Downstream team will create automatic test suites to ensure the functionality of api they consume will work.
7.2 Conformist
In some cases, the upstream team has no incentive to supply for the downstream team’s requirements and the downstream team would then wait forever for the upstream team to respond to their requests. The reason for this might be because of the enterprise politics, overworked upstream team, etc. Since the model is very essential for the downstream subsystem, the team has no choice but to conform to the upstream team.
In my personal experience, I was in such a situation before that due to the enterprise politics, we did not want but have to abide by the upstream team models. In such a case, we gave up the freedom to design our model but to conform to the upstream team model.
To be honest, it was a bittersweet experience that we had no control of the domain model but we still get some features the upstream team rolled out on their models. On the hindsight, there was no need for us to design and maintain the model; so we could just focus on using the model to develop features very quickly. However, we had to maintain an anti-corruption layer to only strip away the out of scope model. This was a pain for us because the upstream team had relatively frequent changes in the model. Even though we had tests to ensure that, the upstream team refused to put the tests into their codebase.
7.3 Separate Ways
When conforming to another team becomes hard, it would come to a point that one substream team decides to go separate ways. In such a scenario, the previous conformist team will regain the power to nurture its own domain models and build solutions upon the rich models. Also, under certain enterprise politics or when communication between teams’ domain models are extremely costly, teams are forced to work only within their own bounded contexts. In such cases, it is also better for the team to go separate ways.
For example, given my personal experience in 7.2. My team could have decided to go separate ways should we have the choice. In that case, we would then develop and maintain our own models, design our own databases, directly talking to the respective domain experts.
It is still strongly suggested that declare a well defined Bounded Context to have no connection to the others at all, allowing developers to still find simple and specified solutions to the problem.
7.4 Anti-Corruption Layer
If you have worked on any real-world enterprise project, you can’t avoid the fact that people are consistently talking about migration away from legacy systems. Instead of building a system completely bottom up without caring about the legacy system, organizations would prefer the team to build pieces to replace the legacy system. In such a case, the two bounded contexts are the legacy system and your new fancy expressive new system that has followed DDD from the very beginning. How do you then protect your new system from being corrupted by the legacy system. Building an anti-corruption layer would definitely help, it would then help the new team to abstract away the complexity and messiness of the legacy system and let the new model stay as clean as possible.
Oftentimes, some design patterns such as Facade and Adapter are wisely suggested to implement the anti-corruption layer.
7.5 Open Host Service
When a subsystem needs to facilitate for quite a number of other subsystems, it becomes tedious for this subsystem to vendor for every single request from other teams.
Open Host Service is a good practise for a subsystem like this to define a protocol that grants access to the system as a set of Services. Open API is a good implementation for this for the teams who are going to integrate.
8. Recap
This comes to the end of Part 3 and the end of the DDD series I am sharing. In this part, I have gone through a few important concepts when developing models. Side effect free functions combined with value objects make the system more predictable and testable and lays the foundation of automatic tests. Intention Revealing Interface makes the ubiquitous language presentable in the codebase and Unleashing the Concept Contours are achieved through relentless refactoring and continuous knowledge crunching. In larger enterprise projects, especially in today’ microservice era, Bounded Context and different relationships between Bounded Contexts should be defined clearly to embrace a more effective collaboration.
In summary, I have briefly gone through the DDD book written by Eric Evans and I hope this could get you started to know more about DDD. I highly recommend anyone who is interested to scan through the book and there are a lot details/tradeoffs that I omitted, which are a little hard to cover in short blog posts like this. You could also check out the codebase here.
I hope you have enjoyed reading the series so far. Feel free to reach out to me.