How modular can your monolith go? Part 3 - encapsulating a subdomain behind a facade
architecting modular monolithThis article is the third 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 4 - physical design principles for faster builds
- Part 5 - decoupling domains with the Observer pattern
- Part 6 - transaction management for commands
- Part 7 - no such thing as a modular monolith?
In part 2, I described a design for a modular monolith where a subdomain’s API consists of its entities and repositories. While this simple approach had some benefits, it suffered from a lack of encapsulation and testability. In this article, I’ll describe an alternative approach where each subdomain exposes a coarse-grained, service-style API.
The drawbacks of the customers
domain’s fine-grained API
In the design described by part 2, the customers
domain’s API consisted of the Customer
entity and the CustomerRepository
.
A key drawback of this design is that it results in tight design time coupling between the customers
domain and the clients of its API, such as orders
domains.
It would be not be possible for the customers
domain to use a different implementation of the Customer
entity without coordinating with the orders
domain.
Another drawback of this design is that an API comprised of entities and repositories is difficult to mock, which makes it difficult to test the orders
domain in isolation.
Encapsulating the customers
domain behind a facade
An alternative API design, which reduces design-time coupling and improves testability, is to encapsulate the customers
domain behind a facade - one of the classic GOF patterns.
Here is a diagram showing the resulting design:
As you can see, the customers
domain is now encapsulated behind the CustomerService
facade.
The orders
domain no longer has a direct dependency on the Customer
entity or the CustomerRepository
.
The CustomerService
class is the API
In this design, the reserveCredit()
, and releaseCredit()
methods were added to the already existing CustomerService
:
class CustomerService {
void reserveCredit(long customerId, long orderId, Money amount) { ... }
void releaseCredit(long customerId, long orderId) { }
CustomerInfo getCustomerInfo(customerId) { ... }
}
There’s also a getCustomerInfo()
method that returns a CustomerInfo
DTO:
The orders
domain now depends on the CustomerService
class
The orders
domain calls customerService.reserveCredit()
and customerService.releaseCredit()
to reserve and release credit.
For example, here’s the OrderService.createOrder()
method:
public class OrderService {
public Order createOrder(long customerId, Money orderTotal) {
Order order = new Order(customerId, orderTotal);
order = orderRepository.save(order);
customerService.reserveCredit(customerId, order.getId(), orderTotal);
order.noteApproved();
var customer = customerService.getCustomerInfo(customerId);
notificationService.sendEmail(customer.emailAddress(), "OrderConfirmation", Map.of("orderId", order.getId()));
return order;
}
In addition to calling CustomerService.reserveCredit()
, it also calls customerService.getCustomerInfo()
to get the customer’s email address.
Let’s now look at the benefits and drawbacks of a facade-based API.
The benefits of a facade
There are two key benefits of using a facade:
- Reduced design-time coupling
- Improved testability
Let’s look at each of these in turn.
Reduced design-time coupling
One key benefit of this approach is that it reduces the risk of tight design-time coupling between the domains.
The underlying implementation of those operations is hidden from the client.
Unlike the previous design, this design does not expose the Customer
JPA entity to the orders
domain.
Consequently, the customers
team is then free to evolve their implementation without having to coordinate with the orders
team.
Moreover, the facade conceals more than just the persistence framework.
It also hides the collaboration patterns of the subdomain’s classes, which in this case includes the credit reservation algorithm.
While today it’s implemented entirely by the Customer
entity, in the future it might be far more elaborate.
For example, instead of a customer having a fixed credit limit, the customers
domain might use a ML model to dynamically determine whether to approve or reject an order.
Since an entity typically doesn’t invoke injectable types (e.g. @Bean
service and repository classes) it’s likely that implementing such an algorithm would require service logic and change the Customer.reserveCredit()
method’s signature or even remove it entirely.
Fortunately, callers of CustomerService.reserveCredit()
don’t need to know or care about the underlying implementation.
In general, to reduce design-time coupling, software elements - classes, modules, microservices etc. - should expose the minimum amount of information to their clients. For example, I like to use the Iceberg metaphor to describe a software element. Similarly, John Ousterhout in his book A Philosophy of Software Design describes how modules should be deep rather than shallow. Facades that expose a minimal interface can be a great way to achieve loose design-coupling.
Note: the customers
domain is still required to participate in the transaction that creates the Order
, which is a constraint on implementation technologies. More on that in a later article.
Improved testability
Another key benefit of a facade is that it’s easy to mock.
We can test a subdomain such as orders
using a mock CustomerService
implementation.
In principle, we can test each domain in isolation, which significantly reduces the complexity of testing as well as test execution time.
The drawbacks of a facade
This approach has a couple of drawbacks:
- Domain models are less rich
- DTO classes and mapping code are often required
Let’s look at each of these in turn.
Domain models are less rich
A key drawback of this approach is that it’s ‘less’ object-oriented due to the prohibition on entity references that span domains.
For example, the Order
entity can no longer reference the Customer
entity.
Instead, it stores the Customer
’s ID:
public class Order {
private long customerId;
As a result, business logic that might have been in entities, such as Order
, which traversed relationships, must be moved into services.
For example, the OrderService.cancel()
method can no longer simply invoke Order.cancel()
.
Instead, it must also invoke CustomerService.releaseCredit()
:
class OrderService
...
public void cancelOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow(...);
order.cancel();
customerService.releaseCredit(order.getCustomerId(), orderId);
}
As a result, logic is moved from the Order
entity into the OrderService
.
The order
domain model is now less rich.
However, given the benefits of loose coupling between domains, this is a trade-off that’s often worth making.
DTO classes and mapping code are often required
Another drawback of using a facade is that it often requires the use of DTO classes.
For example, the CustomerService.getCustomerInfo()
method returns a CustomerInfo
DTO:
As a result, it contains code to map map the Customer
entity to a CustomerInfo
DTO.
In a more complex example, methods might also have DTO parameters, which would then require mapping to entities and value objects.
Such a design can often contain a lot of boilerplate code.
Fortunately, Java 17 records and tools, such as MapStruct, can reduce the amount of boilerplate code that you need to write.
Summary
Encapsulating subdomains behind facades reduces design-time coupling and improves testability.
Using this approach improves the Customer and Orders monolith.
The orders domain
just depends upon the CustomerService
class, which encapsulates the customers
domain.
In other words, the monolith is more modular.
There are, however, a few more improvements that we can make to this design. I’ll describe them in the next article.
Need help with your architecture?
I’m available. I provide consulting and workshops.