How modular can your monolith go? Part 5 - decoupling domains with the Observer pattern
architecting modular monolithThis article is the fifth in a series of articles about modular monoliths. The other articles are:
- Part 1 - the basics
- Part 2 - a first look at how subdomains collaborate
- Part 3 - encapsulating a subdomain behind a facade
- Part 4 - physical design principles for faster builds
- Part 6 - transaction management for commands
- Part 7 - no such thing as a modular monolith?
In previous articles, I described how a domain’s API should consist of one or more narrowly-scoped interfaces, a.k.a. facades, that encapsulate its implementation.
A domain’s client can invoke the domain by calling a facade’s methods.
For example, the Order Management
domain can reserve credit by invoking CreditManagement.reserveCredit()
, which is implemented by the Customer Management
domain.
Similarly, the Customer Management
domain can send a welcome email to a newly registered customer by invoking NotificationService.sendEmail()
, which is implemented by the Notification Management
domain.
But let’s imagine that sending an email is not the only post-registration action that Customer Management
needs to implement.
It might, for example, need to register the customer with the CRM system.
Customer Management
could simply invoke CRMIntegration.registerNewCustomer()
directly.
The drawback, however, is that each action adds another dependency of Customer Management
, which is yet another reason for it to change.
In this article, I look at an alternative approach to API design that potentially minimizes the risk of tight-coupling: the Observer pattern.
The trouble with dependencies
Even though dependencies, or more specifically design-time dependencies, are unavoidable, it’s usually a good idea to minimize them. A software element (class, package, domain, …) often needs to collaborate with others in order to fullfil its responsibilities. The problem, however, is that each dependency is a potential reason to change. Even if each dependency has a stable API, the risk of a lock change is proportional to the number of dependencies. It’s therefore a worthwhile design goal to minimize the number of dependencies.
This is especially true if those dependencies are unrelated to the software element’s core responsibilities.
For example, the Customer Management
domain’s core responsibility is managing customers.
Responsibilities such as sending emails and synchronizing with the CRM system are somewhat secondary.
Moreover, it’s desirable to be able to add a post-registration action without having to modify Customer Management
.
In other words, Customer Management
should conform to the Open-Closed Principle and be open for extension but closed for modification.
Using the Observer pattern to decouple Customer Management
from Notification Management
One way to decouple the Customer Management
domain and to make it extensible is to use the Observer pattern.
The Observer pattern, which is also known as Publish-Subscribe, is a classic object-oriented design pattern - see GOF page 293 - that allows an object (the subject) to notify other objects (the observers) when its state changes without having to know their details.
Let’s look at how this pattern can be used to minimize Customer Management
’s dependencies and enable it to conform to the Open-Closed Principle.
In this particular example, Customer Management
domain is the subject and the post-registration actions are the observers.
Customer Management
only knows the abstract observer type, not the concrete observers.
When a customer is registered, for example, Customer Management
notifies the observers, which then perform their actions.
The following diagram shows the design:
The key parts of the design are:
CustomerDomainObservers
- the interface that observers use to register themselvesCustomerDomainObserver
- the interface that observers must implementCustomerServiceImpl
- the concrete subject classNotificationServiceImpl
- the concrete observer class
Let’s look at each one in more detail.
CustomerDomainObserver
- the observer interface
CustomerDomainObserver
is the interface that observers of the Customer Management
domain must implement.
It defines notification methods, such as noteCustomerCreated()
, that are invoked by the Customer Management
domain.
A key design decision is what information to pass to the observer.
One option is for notification methods to have minimal parameters.
The noteCustomerCreated()
method could, for example, simply take a customerId
argument.
The observer could then retrieve additional information from the Customer Management
domain.
The second option is for notification methods to provide a typical observer with what it needs. Each method could have additional arguments or, better yet, a single parameter object, since that avoids having to change the method signature when additional information is needed. For example:
public interface CustomerDomainObserver {
void noteCustomerCreated(CustomerInfo customer);
}
Both options couple observers to the Customer Management
domain.
However, the first option might enable an observer to leverage the interface segregation principle (ISP) in order to reduce coupling.
Different observers can use different APIs to retrieve the information they need from the Customer Management
domain.
CustomerDomainObservers
- the observer registration interface
CustomerDomainObservers
is an interface that CustomerDomainObserver
instances use to register themselves with the Customer Management
domain.
It defines a single method, register()
, that takes a CustomerDomainObserver
instance as an argument.
CustomerServiceImpl
- the concrete subject class
CustomerServiceImpl
is the concrete subject class.
It implements CustomerDomainObservers
.
Its various methods notify the registered observers.
The createCustomer()
method, for example, invokes the noteCustomerCreated()
method on each registered observer.
It no longer calls, for example, NotificationService.sendEmail()
directly.
NotificationServiceImpl
- the concrete observer class
NotificationServiceImpl
is a concrete observer class.
It implements CustomerDomainObserver
and registers itself with the Customer Management
domain.
The noteCustomerCreated()
method, for example, sends a welcome email to the newly registered customer.
Relationship with events
The Observer pattern is equivalent to an event-based design. Each observer method corresponds to an event type. Perhaps the one difference is that event framework, e.g. Spring’s events, is more generic. You would, for example, only need a single implementation of the publish and subscribe API rather than per-subdomain subjects and subscribers.
Does inverting dependencies with the Observer pattern reduce coupling?
The main consequence of using the Observer pattern is that it inverts the dependencies.
Previously, Customer Management
depended upon Notification Management
(and potentially other domains).
Now, Notification Management
depends upon Customer Management
.
Customer Management
has fewer dependencies, which is potentially beneficial.
But this improvement has been achieved at the expense of Notification Management
, which now has more dependencies.
Which design results in fewer lockstep changes, is highly context specific and depends on the ways in which the domains are likely to change.
Need help with accelerating software delivery?
I’m available to help your organization improve agility and competitiveness through better software architecture: training workshops, architecture reviews, etc.