How modular can your monolith go? Part 4 - physical design principles for faster builds

architecting   modular monolith  

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

In the previous article, I described how using a facade to encapsulate each subdomain’s API can reduce design-time coupling and improve testability. In the example application, the CustomerService facade insulates the orders domain from changes to the customers domain. Minimizing design-time coupling is not the only concern, however. We also need to minimize build times, especially test execution times. In this article, I’ll describe how to use physical design principles to reduce build-time coupling between modules.

A fast deployment pipeline is essential for developer productivity

In order to deliver software rapidly, frequently and reliably, it’s essential to have a fast deployment pipeline. Ideally, a deployment pipeline should take no more than 15 minutes to build, test and begin deployment to production. This will guarantee that a developer will get prompt feedback about their changes. It also ensures that the deployment pipeline will not become an obstacle to continuous delivery, where developers are committing at least daily to trunk.

The challenge with a modular monolith - unlike a microservice architecture - is that you have a single codebase, which could be very large and potentially take a very long time to build and test. Consequently, it’s important to minimize the build-time coupling between the monolith’s modules. The degree of build-time coupling between modules A and B is the likelihood of that a change to A (which requires A to be recompiled and tested), also requires B to be recompiled and/or retested. Ideally, a change to a given module should only require that module to be recompiled and retested, since that will be the fastest possible build. The worst case would be a change to a given module that requires all modules to be recompiled and retested.

Test execution time is often more longer than the compilation time

The build time is the sum of the compilation time and the test execution time. Quite often test execution time is far longer than compilation time. Unit tests are typically very fast since they are entirely in memory. But other types of tests (see the test pyramid) are usually much slower, since they often involve heavyweight technologies, such as networks, databases, and containers.

Modern build tools try to be lazy but …

Modern build tools, such as Maven and Gradle, try to minimize the amount of work that they need to do. For example, a task is skipped if its inputs and outputs are unchanged. A task might only reprocess those input files that have actually changed. Gradle can even avoid recompiling clients of a class whose internal details have changed. Unfortunately, however, build tools typically run all of a module’s tests if any class in either the module or any of its dependencies has changed.

This behavior means that in the example application, a change to the CustomerService class in the customers domain will cause all tests in the orders domain to be re-run even if the change does not require recompilation of the orders domain. As a result, the build time is longer than it needs to be. In a real application, a change to customers domain might trigger the retesting of numerous transitive dependencies, which could take a very long time. The solution is to organize the code by applying some physical design principles.

Apply physical design principles to reduce build-time coupling

As I mentioned a while ago, one of my favorite books from the 1990s is Large Scale C++ Design by John Lakos. While much of the book is C++-specific (not my most favorite language), it makes an important contribution: physical vs. logical software design. Logical software design is the main focus of software design techniques, such as object-oriented design and Domain-Driven Design: naming, assignment of responsibilities to classes and packages, class hierarchies, encapsulation, loose (design-time) coupling, cohesion etc. Ideas that are essential if we want our software to be understandable and maintainable.

Lakos argues, however, that for large scale software, logical design is insufficient. We also must consider the physical design of the software, which is the structure of the application’s physical entities, such as source files, Gradle projects/Maven modules - the file system artifacts consumed by the build tools. That’s because physical design determines, among other things, build-time coupling between modules. Lakos describes insulation techniques (analogous to encapsulation techniques for reducing design-time coupling) that can be used to reduce compile-time coupling.

Using domain API modules to reduce build-time coupling

Let’s imagine that the example application consists of a Gradle subproject (or Maven module) for each domain. We can apply analogous physical design principles to a modular monolith to minimize build-time coupling between those domains. One good technique is to split a domain into an API module (i.e. Gradle subproject) and an implementation module. A domain’s clients depend on the API module, which hopefully rarely changes, and not on the implementation module, which changes more frequently. As a result, changes to the implementation module will not trigger unnecessary rebuilds and retests of the domain’s clients.

For example, in the example application the customers domain would have a customers-api Gradle project and a customers (implementation) Gradle project.

The customers-api module is a dependency of both the customers module and the orders module. The customers-api project contains the CustomerService, which is implemented by the CustomerServiceImpl class in the customers project. Here’s the interface:

public interface CustomerService {
  CustomerInfo createCustomer(String name, Money creditLimit);

  void reserveCredit(long customerId, long orderId, Money orderTotal);

  void releaseCredit(long customerId, long orderId);

  CustomerInfo getCustomerInfo(long customerId);
}

This interface contains the methods that are used by other domains, such as orders, as well as the createCustomer() method, which is called by the CustomerController. The CustomerService interface is, for example, injected into the OrderService:

public class OrderService {

 public OrderService(CustomerService customerService, ...) {...}
...

The customers-api project also contains the CustomerInfo DTO.

The orders module no longer build-time coupled to the customers module and so it will not be rebuilt and retested as a result of changes to the customers module.

Applying the Interface segregation principle (ISP)

One issue with the CustomerService interface, however, is that the createCustomer() method is not used by the orders module. As a result, there’s still unnecessary build-time coupling between the customers and orders domains. Let’s imagine, for example, that you need to add an attribute to Customer entity, which in turn adds a new parameter to the CustomerService.createCustomer() method. Despite not using the createCustomer() method, the orders module will still be rebuilt and retested.

We can further reduce the build-time coupling by applying the Interface segregation principle. The Interface segregation principle states that no client should be forced to depend on methods it does not use. The solution is to split the CustomerService interface into two interfaces: CustomerFactory and CreditManagement, each of which is in a separate API module. There’s a third API module that these two modules depend on.

The customer-factory-api module contains the CustomerFactory interface:

public interface CustomerFactory {
  CustomerInfo createCustomer(String name, Money creditLimit);
}

The CustomerFactory interface is used by the customers web module.

The credit-management-api module contains the CreditManagement interface:

public interface CreditManagement {
  void reserveCredit(long customerId, long orderId, Money orderTotal);

  void releaseCredit(long customerId, long orderId);

  CustomerInfo getCustomerInfo(long customerId);
}

The orders domain depends on this module and uses the CreditManagement interface:

public class OrderService {

 public OrderService(CreditManagement creditManagement, ...) {...}

 public Order createOrder(long customerId, Money orderTotal) {
   ...
   creditManagement.reserveCredit(customerId, order.getId(), orderTotal);
...  

Although this design is more complicated, it reduces the build-time coupling between the customers and orders domains. The two APIs - CustomerFactory and CreditManagement - can evolve independently without triggering unnecessary rebuilds and retests of each other’s clients.

Summary

Using API (Gradle) modules can reduce build-time coupling between modules since changes to an implementation module will only require that module to be rebuilt and retested. Also, using the Interface Segregation principle and defining multiple API modules can further reduce build-time coupling. Only a subset of a module’s clients might be affected by a change to the module’s API.

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