-
Notifications
You must be signed in to change notification settings - Fork 6
An Event Driven Modulith
During development it became clear that some aggregates had dependencies on other aggreagtes in the form of either checks or by triggering changes in other aggregates.
A few examples of checks:
- Before deleting a phase the phase domain service needs to know if there are still tickets that reference that phase. So it would have to ask the ticket domain service for permission before performing a task that the phase domain service should have full control over.
- When creating a ticket, it needs to get assigned to the first phase of its project. So the ticket domain service would need to ask the phase domain service.
- Also during ticket creation the ticket domain service needs to know if the project it got posted to actually exists.
Triggering changes in other aggregates:
- When creating a new account (which means the creation of a new user entity) a number of things are supposed to happen: Creating a default project for the user, creating the related membership and a number of phases for that project.
- Deleting a user also deletes all their memberships and removes them as assignees from tickets. It gets even worse if they already are the last project admin because that would also trigger deletion of these projects, their phases and tickets.
And all these things would need to happen during the transaction of one HTTP request. This was a serious problem and more than just a bad smell.
These dependencies became even more obvious once authorization got added because the aggreates were not able to perform their own security checks without calling the user domain service (to get the matching UUID of the user for the email specified in the email-claim of the JWT).
By introducing events, it was possible to decouple the aggregates a bit.
Each change(create/patch/delete) to an entity of an aggregate now triggers an event containing data about these changes. The published events may then get consumed by other aggregates. If these aggregates are interested in that change, they may store the aspect of that data that is useful to them or even perform other changes within their own boundaries.
This means that the above mentioned checks may now be performed by the aggregates themselves instead of relying on others because they already consumed all the information they need. Also the aggregates do not need to inform other aggregates directly about changes to its entities. They just publish an event and do not need to care what others might do with this information.
Hopefully the following examples will help to illustrate the above mentioned points.
After a ticket got created, the ticket domain service will publish the TicketCreatedEvent:
eventPublisher
.publishEvent(
new TicketCreatedEvent(
initializedTicket.getId(),
initializedTicket.getProjectId(),
postingUserId
)
);
(/ticket/domain/TicketDomainService.java)
Then the phase domain service consumes that event and updates its own data. In this example it just updates a simple counter because this is the only aspect that is of interest for the phase domain service: The number of tickets, not which tickets exactly.
@EventListener
public void handleTicketCreatedEvent(TicketCreatedEvent ticketCreatedEvent) {
Phase firstPhaseOfProject =
getFirstPhaseByProjectId(ticketCreatedEvent.getProjectId()).get();
firstPhaseOfProject.increaseTicketCount();
phaseRepository.save(firstPhaseOfProject);
}
(/phase/domain/PhaseDomainService.java)
Now the phase domain service may perform the check itself without relying on the ticket domain service because it already has the data it needs:
public void deleteById(UUID id) throws NoPhaseFoundException, LastPhaseException {
Phase phase = this.getPhaseById(id);
if (phase.isFirst() && phase.isLast()) {
throw new LastPhaseException(
"The phase with id: " + phase.getId() +
" is already the last phase of the project with id: " + phase.getProjectId() +
" and cannot be deleted."
);
}
if (phase.getTicketCount() != 0) {
throw new PhaseIsNotEmptyException("phase with id: \"" + id + "\" is not empty and can not be deleted");
}
...
}
(/phase/domain/PhaseDomainService.java)
Of course the phase domain service has to also consume other events like the TickePhaseUpdatedEvent and the TicketDeletedEvent to stay updated.
After a project has been posted, a new membership also needs to be created in order for the user to have permission to access the new project.
On project creation the project domain service publishes the ProjectCreatedEvent ...
public Project addProject(Project project, EmailAddress emailAddress) {
UUID userId = getUserIdByUserEmailAddress(emailAddress);
Project initializedProject = projectRepository.save(project);
eventPublisher
.publishEvent(
new ProjectCreatedEvent(
initializedProject.getId(),
userId
)
);
return initializedProject;
}
(/project/domain/ProjectDomainService.java)
... and the membership domain service consumes it. It creates an accepted project admin membership for the user.
@EventListener
@Async
public void handleProjectCreatedEvent(ProjectCreatedEvent projectCreatedEvent) {
projectDataOfMembershipRepository.save(
new ProjectDataOfMembership(
projectCreatedEvent.getProjectId()
)
);
Membership defaultMembership = new Membership(
projectCreatedEvent.getProjectId(),
projectCreatedEvent.getUserId(),
Role.ADMIN
);
this.addDefaultMembership(defaultMembership);
}
(/membership/domain/MembershipDomainService.java)
Also in this case it stores the consumed data in a dedicated repository for consumed project data for later because it needs to know, if a project exists, when a user trys to post a new membership for it.
-
The aggregates got decoupled, enabling them to perform their tasks without relying on other aggregates.
-
Aggregates do not need to explicitly inform others of changes to their entities.
-
This also means that they do not need to keep track of which aggregates need their information. Adding new aggregates which consume data would not mean changes in the code of the publishing aggregates.
-
Changing to an event-driven architecture was also a first step to a modular design which might even lead to what some would call a modulith or a majestic monolith.
A modulith is a description for an architecture that lies somewhere between microservices and monoliths. By strictly decoupling all modules (or aggregates in this case) one would try to benefit from known advantages of microservices while avoiding their overall complexity.
- While the conversion to an event-driven modulith did help in decoupling the aggregates, the inherent dependencies still remain although in the disguise of events.
- The events introduced asynchronous behaviour making it more difficult to keep track of all processes that may be happening at once. This also led to race conditions. Finding and fixing these is hard.
- The Ticketing System is still relatively small but the overhead on published events is already apparent. This makes testing harder and it may even reduce performance once the Ticketing System grows larger which would mean even more events getting published.
- Redundant data: Due to each aggregate consuming and storing data of other aggregates, the consumed data is actually redundant which increases the amount of needed memory space in the database.