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 reservationsclass 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 tableclass 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"));Reservation for the store: yesOnline order: acceptedFree in the warehouse: -1The 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 outsideclass 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 warehouseclass 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"));Reservation for the store: yesOnline order: out of stockFree in the warehouse: 0// Warehouse: stock and the city store's reservationsclass 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 tableclass 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"; }}// Warehouse: stock and the city store's reservationsclass 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 tableclass 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"; }}// The "warehouse" module: the data is its own, only the API is outsideclass 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 warehouseclass Orders(Warehouse warehouse){ public string Place(string sku, int qty) { if (!warehouse.Reserve(sku, qty)) return "out of stock"; return "accepted"; }}// 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 messageclass 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 serviceclass 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; } }}Step by step, in words
- One process, one database. The warehouse keeps stock and reservations, and its tables are open to everyone.
- The store reserves both kettles. The online order reaches straight into the table, misses the reservation, and free stock becomes -1.
- A modular monolith: the data of the warehouse becomes its own. Only the API stays outside: receive, reserve, available.
- Orders go through reserve, and the warehouse keeps its rule: "out of stock", free stays 0. Still one process, no network.
- The warehouse leaves as a separate service: its own process, deploy and team. A call becomes a message over the network.
- Three orders, three messages: ok, silence, no. A call that could not fail inside the monolith now fails.
- 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.
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.
The method returns the table itself, not a copy. Anyone who gets it can write to it (stockTable().put(...)), and the border is open again, only better hidden. The module should return a copy, or only what the report really needs, for example a list of "product and free items".
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 outsidevar api = new Dictionary<string, HashSet<string>>{ ["orders"] = ["place"], ["warehouse"] = ["receive", "reserve", "available"], ["payments"] = ["charge"], ["invoices"] = ["issue"],};// Which module may depend on whichvar 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);✗ orders → warehouse.stock: not in the API✗ invoices → orders.rows: not in the API✗ warehouse → orders: this dependency is not allowedViolations: 3Three 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
JOINbetween 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 messageclass 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 serviceclass 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);Order 1: acceptedOrder 2: waiting: the warehouse does not answerOrder 3: out of stockMessages over the network: 3The 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);Each order sends its own message, so there are three. The first two get no answer and wait, the third is accepted. How many times and when to try again is a separate topic: the lesson When the neighbour is slow.
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.
✗ orders ↔ warehouseViolations: 1More exercises
- A cycle through three modules.
orders → warehouse → invoices → ordersis 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 (
invoicesreadsorders.rows) and print every table that more than one module uses.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Repository · SOLID without dogma · Layers · Hexagonal and clean architecture
- Continues in: Events and queues · Transactional outbox · Scaling · ADR: architecture decision records
- Tasks on this topic: Software architecture