The Spring Modulith: Monolithic but Manageable

Ritesh Shergill
7 min readJul 29, 2023

--

If you haven’t been living under a rock for the past few years (as a software engineer) you know about the age old debate — Monolith Vs Microservices.

Here’s a fact — Both are equally useful and it all depends on the use case.

But if you are going the monolith way, it’s worth checking out Spring’s Modulith approach.

Spring Modulith allows developers to build well-structured Spring Boot applications and guides developers in finding and working with application modules driven by the domain.

It allows developers to

1️⃣ Structure modules into well defined packages

2️⃣ Have clear separation of concern

3️⃣ Create a maintainable and structured Monolith

4️⃣ Follow good design patterns and principles to design a monolith that can stand the test of time

Apart from the above benefits, the Spring Modulith arrangement also allows developers to:

1️⃣ Verify modular arrangements

2️⃣ Integration test modules with each other

3️⃣ Observe the modules as well as the application behavior

4️⃣ Create documentation snippets based on the modular arrangement

Suffice to say that the Modulith pattern is a great way to organize your monolith into modules that are naturally dependent on each other.

Let us look at the Modulith implementation with an example

Requirement: We have a simple Inventory management application with 3 sports goods

Basketballs, cricket bats and footballs.

We have an inventory management service that creates the prices and quantities in stock for each item at application startup.

We have an accounts service that has a hard coded account balance of 1000 units which reduces by quantity * price on each purchase.

For the intents of this article, we are using Spring data JPA to connect to an in memory h2 database.

This is what our modulith structure looks like

This is a screenshot from Intellij Idea, my IDE of choice. Always is and always will be.

According to our modulith setup, we have 2 modules —

— Accounts

— Inventory

We have an accounts service that has a hardcoded account balance assuming that there is only one user in the system. Code is as follows

@Service
public class AccountService {

public int totalFunds = 1000;

//check for availability of balance before making the purchase
public void checkFunds(int totalPurchaseValue) throws Exception {
if(totalFunds - totalPurchaseValue < 0) {
throw new Exception("Funds not available to complete the purchase pls reduce items in the inventory!");
}
}

//reduce the total balance by the transaction amount of the purchased goods
public int completePurchase(int totalPurchaseValue) {
this.setTotalFunds(totalFunds - totalPurchaseValue);
return this.getTotalFunds();
}

public int getTotalFunds() {
return totalFunds;
}

public void setTotalFunds(int totalFunds) {
this.totalFunds = totalFunds;
}
}

We first check for availability of funds to make the purchase and if there is no exception, we proceed with the transaction.

Caveat: This is a very simplistic example just to show how the modulith organizes modules and dependencies

Next, we have the Inventory Management service that does the following -

Step 1: On startup, add the inventory total stock available (quantity), price of each unit and the Inventory Type.

Code for Step 1:

@PostConstruct
public void populateInventory() {
InventoryEntity basketball = new InventoryEntity();
InventoryEntity cricketBat = new InventoryEntity();
InventoryEntity football = new InventoryEntity();
basketball.setId(1);
basketball.setInventoryType(InventoryType.BASKETBALL.toString());
basketball.setPrice(100);
basketball.setQuantity(10);
cricketBat.setId(2);
cricketBat.setInventoryType(InventoryType.CRICKETBAT.toString());
cricketBat.setPrice(500);
cricketBat.setQuantity(5);
football.setId(3);
football.setInventoryType(InventoryType.FOOTBALL.toString());
football.setPrice(300);
football.setQuantity(30);
inventoryRepository.save(basketball);
inventoryRepository.save(cricketBat);
inventoryRepository.save(football);
}

We use the Spring bean @PostConstruct annotation to populate the database on startup.

Step 2: Expose the purchase method to allow purchasing of an inventory item.

Code for Step 2:

public int purchase(String inventoryType, int quantity) throws Exception {
InventoryType invType = null;
InventoryEntity item = inventoryRepository.findByinventoryType(inventoryType);
if(item == null) {
throw new Exception("Please supply correct inventory type");
}
int currPrice = quantity * item.getPrice();
item.setQuantity(item.getQuantity() - quantity);
inventoryRepository.save(item);
try {
accountService.checkFunds(currPrice);
} catch(Exception e) {
System.out.println("Cannot make the purchase as there are no funds available!");
return -1;
}

int bal = accountService.completePurchase(currPrice);
System.out.println("Balance left: " + bal);
return bal;
}

At the moment we only allow purchasing of one item at a time which is not realistic but serves the intents and purposes of our article.

Notice that we have directly referenced the AccountService here which will be injected when the Bean is instantiated.

The entire code for the InventoryManagementService bean:

@Service
public class InventoryManagementService {

@Autowired
private InventoryRepository inventoryRepository;

private final AccountService accountService;

public InventoryManagementService(AccountService accountService) {
this.accountService = accountService;
}

@PostConstruct
public void populateInventory() {
InventoryEntity basketball = new InventoryEntity();
InventoryEntity cricketBat = new InventoryEntity();
InventoryEntity football = new InventoryEntity();
basketball.setId(1);
basketball.setInventoryType(InventoryType.BASKETBALL.toString());
basketball.setPrice(100);
basketball.setQuantity(10);
cricketBat.setId(2);
cricketBat.setInventoryType(InventoryType.CRICKETBAT.toString());
cricketBat.setPrice(500);
cricketBat.setQuantity(5);
football.setId(3);
football.setInventoryType(InventoryType.FOOTBALL.toString());
football.setPrice(300);
football.setQuantity(30);
inventoryRepository.save(basketball);
inventoryRepository.save(cricketBat);
inventoryRepository.save(football);
}

public int purchase(String inventoryType, int quantity) throws Exception {
InventoryType invType = null;
InventoryEntity item = inventoryRepository.findByinventoryType(inventoryType);
if(item == null) {
throw new Exception("Please supply correct inventory type");
}
int currPrice = quantity * item.getPrice();
item.setQuantity(item.getQuantity() - quantity);
inventoryRepository.save(item);
try {
accountService.checkFunds(currPrice);
} catch(Exception e) {
System.out.println("Cannot make the purchase as there are no funds available!");
return -1;
}

int bal = accountService.completePurchase(currPrice);
System.out.println("Balance left: " + bal);
return bal;
}

}

Step 3: To test this, we simply purchase an item on application startup:

@EnableJpaRepositories(basePackages = "com.example.modulith.inventory.repository")
@SpringBootApplication
public class ModulithApplication {

public static void main(String[] args) throws Exception {

SpringApplication.run(ModulithApplication.class, args)
.getBean(InventoryManagementService.class)
.purchase(String.valueOf(InventoryType.BASKETBALL), 5);
}

}

We are purchasing 5 basketballs on startup.

When we run the application and check our h2 database, we see that the Basketballs had an initial quantity of 10. Post app startup we see in the database:

The quantity has been reduced to 5.

And the total balance is printed as

So we see that

  1. Our code is well organized into modules with clear separation of concern
  2. We can separate out logic such as database connections, 3rd party tool integrations etc. into separate modules.
  3. We can test interaction between modules.
  4. We can use the Modulith testing framework to verify our modular structure and ensure we are not breaking the pattern.
@SpringBootTest
class ModulithApplicationTests {

@Test
void contextLoads() {
}

@Test
void verifiesModularStructure() {
ApplicationModules modules = ApplicationModules.of(ModulithApplication.class);
modules.verify();
}

}

Result:

And thus we meet the requirements for Encapsulation and abstraction as well.

Even if we have large teams, we can distribute teams module wise and then manage cross module dependencies in a more concerted and clear way.

🔷When NOT to use a modulith

❌When its likely that some modules may have too many cross module dependencies.

❌When a transaction spans multiple modules and is thus fail fast.

❌When a module starts to have too many classes and becomes unweildy.

❌To avoid the ‘Ball of Mud’ and ‘Sphagetti code’ antipatterns.

🟢When to use a modulith

✔️ To rapidly develop services with clear responsibilities and are not likely to change significantly over a few years

✔️ When traffic is almost evenly spread across services and thus, the application is long living.

✔️ When the requirements are clear from the beginning and not likely to change significantly

✔️ For rapid prototyping and POCs.

✔️ When you already know you will be deploying to a single large machine, maybe 2 that are load balanced and expect vertical scaling instead of horizontal.

✔️ When you don’t want the headache of CI CD as well as shipping multiple apps.

At the end of the day, choosing an architecture pattern is a very critical business decision. The choice of architecture should always be driven by business constraints, risks and forecasting.

I have consulted several startups over the past 2 years or so and the biggest mistake they make is considering a particular architecture type like a silver bullet. It never is. Neither is your choice of technology.

I have worked with several so called architects who can’t tell a Bean apart from a Factory. These are the people who make key decisions for the organization when it comes to technology and then shoot themselves in the foot.

The reason I would push for a modulith (in business terms) is always to rapidly develop and get to market to check for

▶️ Product Market Fit

▶️ Get timely user feedback

▶️ Chart out an initial Domain Driven design

▶️ Decide my database structure

▶️ Iron out the chinks in requirements, design etc. early and iteratively.

Follow me Ritesh Shergill

for more articles on

— Tech

— Career advice

— User Experience

— And other interesting stuff

I also consult startups on all things tech as well as Go to market so you can connect with me on Topmate.io if you want a consultation

https://topmate.io/ritesh_shergill/193786

--

--

Ritesh Shergill
Ritesh Shergill

Written by Ritesh Shergill

Senior Data and Systems Architect | AI ML and Software Architecture Consultations | Career Guidance | Ex VP at JP Morgan Chase | Startup Mentor | Author

No responses yet