How modular can your monolith go? Part 3 - encapsulating a subdomain behind a facade

architecting   modular monolith  

This article is the third in a series of articles about modular monoliths. The other articles are:

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.


architecting   modular monolith  


Copyright © 2024 Chris Richardson • All rights reserved • Supported by Kong.

About Microservices.io

Microservices.io is brought to you by Chris Richardson. Experienced software architect, author of POJOs in Action, the creator of the original CloudFoundry.com, and the author of Microservices patterns.

ASK CHRIS

?

Got a question about microservices?

Fill in this form. If I can, I'll write a blog post that answers your question.

NEED HELP?

I help organizations improve agility and competitiveness through better software architecture.

Learn more about my consulting engagements, and training workshops.

LEARN about microservices

Chris offers numerous other resources for learning the microservice architecture.

Get the book: Microservices Patterns

Read Chris Richardson's book:

Example microservices applications

Want to see an example? Check out Chris Richardson's example applications. See code

Virtual bootcamp: Distributed data patterns in a microservice architecture

My virtual bootcamp, distributed data patterns in a microservice architecture, is now open for enrollment!

It covers the key distributed data management patterns including Saga, API Composition, and CQRS.

It consists of video lectures, code labs, and a weekly ask-me-anything video conference repeated in multiple timezones.

The regular price is $395/person but use coupon NPXJKULI to sign up for $95 (valid until December 25th, 2024). There are deeper discounts for buying multiple seats.

Learn more

Learn how to create a service template and microservice chassis

Take a look at my Manning LiveProject that teaches you how to develop a service template and microservice chassis.

Signup for the newsletter


BUILD microservices

Ready to start using the microservice architecture?

Consulting services

Engage Chris to create a microservices adoption roadmap and help you define your microservice architecture,


The Eventuate platform

Use the Eventuate.io platform to tackle distributed data management challenges in your microservices architecture.

Eventuate is Chris's latest startup. It makes it easy to use the Saga pattern to manage transactions and the CQRS pattern to implement queries.


Join the microservices google group