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
{
public Dictionary<string, int> Stock { get; } = [];
public Dictionary<string, int> Reserved { get; } = [];
public bool Reserve(string sku, int qty)
{
if (Free(sku) < qty) return false;
Reserved[sku] = Reserved.GetValueOrDefault(sku) + qty;
return true;
}
public int Free(string sku) =>
Stock.GetValueOrDefault(sku) - Reserved.GetValueOrDefault(sku);
}
// Orders: they reach straight into the warehouse's table
class Orders(Warehouse warehouse)
{
public string Place(string sku, int qty)
{
int left = warehouse.Stock[sku];
if (left < qty) return "out of stock";
warehouse.Stock[sku] = left - qty;
return "accepted";
}
}
var warehouse = new Warehouse();
warehouse.Stock["KTL-100"] = 2;
bool held = warehouse.Reserve("KTL-100", 2);
Console.WriteLine("Reservation for the store: " + (held ? "yes" : "no"));
var orders = new Orders(warehouse);
Console.WriteLine("Online order: " + orders.Place("KTL-100", 1));
Console.WriteLine("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 readonly Dictionary<string, int> stock = [];
private readonly Dictionary<string, int> reserved = [];
public void Receive(string sku, int qty) =>
stock[sku] = stock.GetValueOrDefault(sku) + qty;
public bool Reserve(string sku, int qty)
{
if (Available(sku) < qty) return false;
reserved[sku] = reserved.GetValueOrDefault(sku) + qty;
return true;
}
public int Available(string sku) =>
stock.GetValueOrDefault(sku) - reserved.GetValueOrDefault(sku);
}
// The "orders" module: it calls only the API of the warehouse
class Orders(Warehouse warehouse)
{
public 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);
bool held = warehouse.Reserve("KTL-100", 2);
Console.WriteLine("Reservation for the store: " + (held ? "yes" : "no"));
var orders = new Orders(warehouse);
Console.WriteLine("Online order: " + orders.Place("KTL-100", 1));
Console.WriteLine("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
{
public Dictionary<string, int> Stock { get; } = [];
public Dictionary<string, int> Reserved { get; } = [];
public bool Reserve(string sku, int qty)
{
if (Free(sku) < qty) return false;
Reserved[sku] = Reserved.GetValueOrDefault(sku) + qty;
return true;
}
public int Free(string sku) =>
Stock.GetValueOrDefault(sku) - Reserved.GetValueOrDefault(sku);
}
// Orders: they reach straight into the warehouse's table
class Orders(Warehouse warehouse)
{
public string Place(string sku, int qty)
{
int left = warehouse.Stock[sku];
if (left < qty) return "out of stock";
warehouse.Stock[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 C# private protects the fields, and internal hides classes from other projects. A module is often a separate project (.csproj) in the same solution: what is internal cannot be seen from outside.

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);
static class Boundaries
{
public static List<string> Check(Dictionary<string, HashSet<string>> api,
Dictionary<string, HashSet<string>> allowed, List<Use> uses)
{
var problems = new List<string>();
foreach (var (from, to, name) in uses)
{
if (!allowed[from].Contains(to))
problems.Add($"✗ {from} → {to}: this dependency is not allowed");
else if (!api[to].Contains(name))
problems.Add($"✗ {from} → {to}.{name}: not in the API");
}
return problems;
}
}
// What each module offers outside
var api = new Dictionary<string, HashSet<string>>
{
["orders"] = ["place"],
["warehouse"] = ["receive", "reserve", "available"],
["payments"] = ["charge"],
["invoices"] = ["issue"],
};
// Which module may depend on which
var allowed = new Dictionary<string, HashSet<string>>
{
["orders"] = ["warehouse", "payments"],
["warehouse"] = [],
["payments"] = [],
["invoices"] = ["orders"],
};
// What the code really uses (from the imports)
List<Use> uses =
[
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(Console.WriteLine);
Console.WriteLine("Violations: " + problems.Count);
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 IWarehouse and do not know there is a network behind it:

interface IWarehouse
{
bool Reserve(string sku, int qty);
}
// The network is a recorded script: "ok", "no" or "timeout"
class Network(params string[] script)
{
private readonly Queue<string> queue = new(script);
public int Sent { get; private set; }
public string Send(string message)
{
Sent++;
return queue.Dequeue();
}
}
// The warehouse as a separate service: every call is a message
class RemoteWarehouse(Network network) : IWarehouse
{
public bool Reserve(string sku, int qty)
{
var answer = network.Send($"reserve {sku} {qty}");
if (answer == "timeout")
throw new TimeoutException("the warehouse does not answer");
return answer == "ok";
}
}
// Orders do not know if the warehouse is a module or a service
class Orders(IWarehouse warehouse)
{
public string Place(string sku, int qty)
{
try
{
if (!warehouse.Reserve(sku, qty)) return "out of stock";
return "accepted";
}
catch (TimeoutException e)
{
return "waiting: " + e.Message;
}
}
}
var network = new Network("ok", "timeout", "no");
var orders = new Orders(new RemoteWarehouse(network));
for (int i = 1; i <= 3; i++)
Console.WriteLine($"Order {i}: {orders.Place("KTL-100", 1)}");
Console.WriteLine("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++)
Console.WriteLine($"Order {i}: {orders.Place("KTL-100", 1)}");
Console.WriteLine("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 ·