Skip to content
wannacode

Monolith, modular monolith, services

In short: A modular monolith is one application with clear modules, each with a public API and its own data; microservices are split out when teams, scale or independent deploys are worth the cost.

Problem

Orders in the warehouse table

The shop in the city reserves items for customers who will come in person. The warehouse keeps the stock and the reservations, and for the kettle KTL-100 the stock is 2. The online orders were written by another team, which "to save time" reads and writes the warehouse table directly:

// Warehouse: stock and the city store's reservations
class Warehouse {
final Map<String, Integer> stock = new HashMap<>();
final Map<String, Integer> reserved = new HashMap<>();
boolean reserve(String sku, int qty) {
if (free(sku) < qty) return false;
reserved.merge(sku, qty, Integer::sum);
return true;
}
int free(String sku) {
int held = reserved.getOrDefault(sku, 0);
return stock.getOrDefault(sku, 0) - held;
}
}
// Orders: they reach straight into the warehouse's table
class Orders {
private final Warehouse warehouse;
Orders(Warehouse warehouse) {
this.warehouse = warehouse;
}
String place(String sku, int qty) {
int left = warehouse.stock.get(sku);
if (left < qty) return "out of stock";
warehouse.stock.put(sku, left - qty);
return "accepted";
}
}
var warehouse = new Warehouse();
warehouse.stock.put("KTL-100", 2);
boolean held = warehouse.reserve("KTL-100", 2);
System.out.println("Reservation for the store: " + (held ? "yes" : "no"));
var orders = new Orders(warehouse);
System.out.println("Online order: " + orders.place("KTL-100", 1));
System.out.println("Free in the warehouse: " + warehouse.free("KTL-100"));
Output
Reservation for the store: yes
Online order: accepted
Free in the warehouse: -1

The shop has reserved both kettles, and the online order is accepted. Free stock is -1: an item that does not exist has been sold. The rule for reservations is in the warehouse, but the orders do not go through it.

The second pain comes later. The warehouse team wants to split stock by warehouse (Sofia, Plovdiv). It cannot: it does not know who else reads and writes its table, and every change breaks someone else's code.

The idea

Modules with an API and their own data

The code is one program that is deployed all at once: a monolith. That alone is not the trouble. The trouble is that there are no borders inside.

A modular monolith is a monolith split into modules. Each module:

  • has a public API: a few methods the others call it through (receive, reserve, available);
  • has its own data: the tables and classes inside are its own, and nobody else reads or writes them;
  • depends only on the modules it is allowed to.

This is the "by feature" layout from the lesson Layers, with a rule for the borders. In domain-driven design such a border is called a bounded context: the part of the system in which the word "stock" has one meaning.

The modules are in one process and often in one database. Calling a module is calling a method: fast, with no network, and in one transaction if needed.

See it

The warehouse as a module

// The "warehouse" module: the data is its own, only the API is outside
class Warehouse {
private final Map<String, Integer> stock = new HashMap<>();
private final Map<String, Integer> reserved = new HashMap<>();
void receive(String sku, int qty) {
stock.merge(sku, qty, Integer::sum);
}
boolean reserve(String sku, int qty) {
if (available(sku) < qty) return false;
reserved.merge(sku, qty, Integer::sum);
return true;
}
int available(String sku) {
int held = reserved.getOrDefault(sku, 0);
return stock.getOrDefault(sku, 0) - held;
}
}
// The "orders" module: it calls only the API of the warehouse
class Orders {
private final Warehouse warehouse;
Orders(Warehouse warehouse) {
this.warehouse = warehouse;
}
String place(String sku, int qty) {
if (!warehouse.reserve(sku, qty)) return "out of stock";
return "accepted";
}
}
var warehouse = new Warehouse();
warehouse.receive("KTL-100", 2);
boolean held = warehouse.reserve("KTL-100", 2);
System.out.println("Reservation for the store: " + (held ? "yes" : "no"));
var orders = new Orders(warehouse);
System.out.println("Online order: " + orders.place("KTL-100", 1));
System.out.println("Free in the warehouse: " + warehouse.available("KTL-100"));
Output
Reservation for the store: yes
Online order: out of stock
Free in the warehouse: 0

// Warehouse: stock and the city store's reservations
class Warehouse {
final Map<String, Integer> stock = new HashMap<>();
final Map<String, Integer> reserved = new HashMap<>();
boolean reserve(String sku, int qty) {
if (free(sku) < qty) return false;
reserved.merge(sku, qty, Integer::sum);
return true;
}
int free(String sku) {
int held = reserved.getOrDefault(sku, 0);
return stock.getOrDefault(sku, 0) - held;
}
}
// Orders: they reach straight into the warehouse's table
class Orders {
private final Warehouse warehouse;
Orders(Warehouse warehouse) {
this.warehouse = warehouse;
}
String place(String sku, int qty) {
int left = warehouse.stock.get(sku);
if (left < qty) return "out of stock";
warehouse.stock.put(sku, left - qty);
return "accepted";
}
}
Step by step, in words
  1. One process, one database. The warehouse keeps stock and reservations, and its tables are open to everyone.
  2. The store reserves both kettles. The online order reaches straight into the table, misses the reservation, and free stock becomes -1.
  3. A modular monolith: the data of the warehouse becomes its own. Only the API stays outside: receive, reserve, available.
  4. Orders go through reserve, and the warehouse keeps its rule: "out of stock", free stays 0. Still one process, no network.
  5. The warehouse leaves as a separate service: its own process, deploy and team. A call becomes a message over the network.
  6. Three orders, three messages: ok, silence, no. A call that could not fail inside the monolith now fails.
  7. Modules keep the borders without a network. A service adds an independent deploy, and pays with the network, split data and monitoring.

The order goes through reserve, and the warehouse keeps its rule: both kettles are reserved, so the online order gets "out of stock". The last steps of the scene show what happens when the warehouse leaves as a separate service. Their code is further down.

In Java private protects the fields, and packages and module-info.java (JPMS) protect whole groups of classes: what a module does not exports cannot be seen from outside. Spring Modulith checks the borders between modules in a Spring application.

Try it

Find the bug

The warehouse report wants to show the whole table, and someone adds a method:

Pick the line with the bug.

See it

Checking the borders

Borders are not kept by good intentions. Here is a small check: what each module offers, which module may depend on which, and what the code really uses. In a real project the last list is read from the imports.

record Use(String from, String to, String name) {}
class Boundaries {
static List<String> check(Map<String, Set<String>> api,
Map<String, Set<String>> allowed, List<Use> uses) {
List<String> problems = new ArrayList<>();
for (Use u : uses) {
String edge = u.from() + " → " + u.to();
if (!allowed.get(u.from()).contains(u.to()))
problems.add("✗ " + edge + ": this dependency is not allowed");
else if (!api.get(u.to()).contains(u.name()))
problems.add("✗ " + edge + "." + u.name() + ": not in the API");
}
return problems;
}
}
// What each module offers outside
Map<String, Set<String>> api = Map.of(
"orders", Set.of("place"),
"warehouse", Set.of("receive", "reserve", "available"),
"payments", Set.of("charge"),
"invoices", Set.of("issue"));
// Which module may depend on which
Map<String, Set<String>> allowed = Map.of(
"orders", Set.of("warehouse", "payments"),
"warehouse", Set.of(),
"payments", Set.of(),
"invoices", Set.of("orders"));
// What the code really uses (from the imports)
var uses = List.of(
new Use("orders", "warehouse", "reserve"),
new Use("orders", "warehouse", "stock"),
new Use("orders", "payments", "charge"),
new Use("invoices", "orders", "rows"),
new Use("warehouse", "orders", "place"));
var problems = Boundaries.check(api, allowed, uses);
problems.forEach(System.out::println);
System.out.println("Violations: " + problems.size());
Output
✗ orders → warehouse.stock: not in the API
✗ invoices → orders.rows: not in the API
✗ warehouse → orders: this dependency is not allowed
Violations: 3

Three violations: the orders in the warehouse table, the invoices in the order rows, and a warehouse that calls the orders (while the orders already call the warehouse). Such a check runs on every change and fails as soon as someone goes through the wall. How it becomes part of CI is in the lesson Architecture review.

The idea

When a module becomes a service

A service (microservice) is a module that runs on its own: its own process, its own deploy, its own database. The others call it over the network. It is worth it when:

  • teams get in each other's way: the warehouse wants to ship a new version three times a day, and the shop once a week;
  • load differs: catalogue search needs ten copies, and invoices need one;
  • failure must be isolated: if recommendations go down, payment must keep working.

And it is paid for with:

  • the network: a call may be slow, may not arrive, or may arrive twice;
  • data: each service has its own database, so there is no JOIN between them and no single transaction. An order and a reservation are no longer saved "together". How to live with that is in Events and queues, The lost event and Saga;
  • monitoring: one request goes through five processes, and the error is searched for in five logs.

Servers, containers, Kubernetes and monitoring as infrastructure are the subject of the ZeroToOps DevOps course, not of this lesson.

See it

The warehouse over the network

The warehouse is a separate service. In the program the "network" is an object with a recorded script: to each message it answers ok, no, or stays silent (timeout). The orders see only the API WarehouseApi and do not know there is a network behind it:

interface WarehouseApi {
boolean reserve(String sku, int qty);
}
// The network is a recorded script: "ok", "no" or "timeout"
class Network {
private final Deque<String> script;
int sent = 0;
Network(String... script) {
this.script = new ArrayDeque<>(List.of(script));
}
String send(String message) {
sent++;
return script.removeFirst();
}
}
// The warehouse as a separate service: every call is a message
class RemoteWarehouse implements WarehouseApi {
private final Network network;
RemoteWarehouse(Network network) {
this.network = network;
}
public boolean reserve(String sku, int qty) {
String answer = network.send("reserve " + sku + " " + qty);
if (answer.equals("timeout"))
throw new IllegalStateException("the warehouse does not answer");
return answer.equals("ok");
}
}
// Orders do not know if the warehouse is a module or a service
class Orders {
private final WarehouseApi warehouse;
Orders(WarehouseApi warehouse) {
this.warehouse = warehouse;
}
String place(String sku, int qty) {
try {
if (!warehouse.reserve(sku, qty)) return "out of stock";
return "accepted";
} catch (IllegalStateException e) {
return "waiting: " + e.getMessage();
}
}
}
var network = new Network("ok", "timeout", "no");
var orders = new Orders(new RemoteWarehouse(network));
for (int i = 1; i <= 3; i++)
System.out.println("Order " + i + ": " + orders.place("KTL-100", 1));
System.out.println("Messages over the network: " + network.sent);
Output
Order 1: accepted
Order 2: waiting: the warehouse does not answer
Order 3: out of stock
Messages over the network: 3

The second order does not know whether the item is reserved: the warehouse may have received the message and answered, and the answer may have been lost. In the module this question did not exist.

Try it

Predict the output

The network stays silent twice, then answers ok. What does the program print?

var network = new Network("timeout", "timeout", "ok");
var orders = new Orders(new RemoteWarehouse(network));
for (int i = 1; i <= 3; i++)
System.out.println("Order " + i + ": " + orders.place("KTL-100", 1));
System.out.println("Messages over the network: " + network.sent);
Answer

The idea

When services hurt

  • Too early. A new shop with one team and ten services pays the whole price of the network without needing independent deploys. The borders are not clear yet, and moving a border between services costs far more than between modules.
  • A distributed monolith. Services that must be deployed together and call one another on every request have all the drawbacks of a monolith and of services at once.
  • A shared database. Two services that write to the same tables are the problem from the start of the lesson, only over the network.

That is why the common path is modular monolith first: clear modules in one process and a boundary check. A module that really needs to be separate leaves as a service. That is easy if it already has an API and its own data, like the warehouse in the scene.

More precisely, if you are curious

The frameworks. A service is an ordinary application: Spring Boot, ASP.NET Core, FastAPI or Django, Express or Nest. Services talk over HTTP (REST, gRPC) or through a message broker (Kafka, RabbitMQ). In front there is often an API gateway: one entrance that routes requests to the services and checks the input.

Conway's law. Systems resemble the organisation that builds them: three teams build a system of three parts. That is why the borders of services often follow the teams.

Checking the borders. ArchUnit (Java) and ArchUnitNET or NetArchTest (C#) write the check as a test. import-linter (Python) and dependency-cruiser (JavaScript) write it as rules in configuration. Spring Modulith (Java) and Nx (JavaScript) also know about modules.

How big a service is. "Micro" is not a size in lines. A good border is one that few calls go through, and behind which there is data nobody else writes.

Exercise

A cycle between modules

Solve it in your editor.

Someone has also allowed warehouse → orders. Now the two modules depend on each other. Write a function that takes the map "which module may depend on which" and finds every pair of modules that depend on each other. Each pair is printed once, in alphabetical order.

Output
✗ orders ↔ warehouse
Violations: 1

More exercises

  • A cycle through three modules. orders → warehouse → invoices → orders is a cycle too. Find it with depth-first search (the lesson DFS finds cycles).
  • The tables. Add to the check which module reads whose table (invoices reads orders.rows) and print every table that more than one module uses.

Test

Check yourself

1. What makes a module a module, and not just a folder?
2. Orders write straight into the stock table. What is the trouble?
3. Which one is a good reason to make the warehouse a separate service?
4. The warehouse is a service and does not answer. Which of these is a new problem the monolith did not have?
5. The boundary check allows both orders → warehouse and warehouse → orders. Why is that bad?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·