Monolith and modular monolith
Most products start as one application, and many should stay that way far longer than fashion suggests. This page builds Pedal as a modular monolith, shows the transaction you get for free, and measures the cost that usually ends it: teams queuing for one release pipeline.
1. In one picture
A monolith is one deployable application: all features are built, tested and released together, run in one process (copied onto as many servers as needed) and usually share one database. Calls between features are ordinary function calls.
"Monolith" is not the same as "mess". A big ball of mud is a monolith with no internal boundaries, where any code can call or query anything. A modular monolith keeps the single deployable but divides it into modules with walls: each module has a small public interface, owns its own tables, and the walls are checked by tools in the build.
2. Pedal built this way
Pedal as a modular monolith: three modules inside one application and one database. Follow a return at a full dock and then at a free one.
The whole return (close the ride, lock the bike, price it, charge it) is one database transaction. That's the monolith's quiet superpower: if any step fails, nothing happened, with no compensation logic to write. The same flow split into services needs a saga (Architecture module 24).
3. The rules
| Rule | Why it exists | Broken when… |
|---|---|---|
| One deployable unit | One build, one test run, one release: simple to run and reason about. | Parts get released on separate schedules but still share code and tables: a distributed monolith. |
| Modules talk only through public interfaces | A module's internals can change without touching the others. | Billing reads rides columns directly "because it's quicker". |
| Each module owns its tables | Data has one owner, so a module can be extracted later without untangling queries. | A report joins five modules' tables in one SQL statement. |
| Boundaries are checked in the build | Rules nobody enforces erode within months. | The import-rule check is switched off to make a deadline, and never back on. |
4. How it goes wrong
5. The code
Pedal as one application with three modules and one database. Each module owns its own tables and exposes a few methods; the return flow runs as one transaction, so a full dock rolls everything back.
from __future__ import annotations
import copy
from contextlib import contextmanager
class DockFull(Exception):
pass
class Database:
"""One database for the whole app. Each module owns its own tables in it."""
def __init__(self) -> None:
self.tables: dict[str, dict] = {"docks": {}, "rides": {}, "charges": {}}
@contextmanager
def transaction(self):
before = copy.deepcopy(self.tables)
try:
yield
except Exception:
self.tables = before # all or nothing, across every module's tables
raise
# ---- Module: docks. Other modules use only these methods, never the "docks" table. ----
class Docks:
def __init__(self, db: Database) -> None:
self.db = db
def add(self, dock: str, bikes: list[str], slots: int) -> None:
self.db.tables["docks"][dock] = {"bikes": list(bikes), "slots": slots}
def release(self, dock: str) -> str:
return self.db.tables["docks"][dock]["bikes"].pop(0)
def lock(self, dock: str, bike: str) -> None:
d = self.db.tables["docks"][dock]
if len(d["bikes"]) >= d["slots"]:
raise DockFull(dock)
d["bikes"].append(bike)
# ---- Module: rides. ----
class Rides:
def __init__(self, db: Database) -> None:
self.db = db
def start(self, rider: str, bike: str) -> str:
ride_id = f"R{len(self.db.tables['rides']) + 1}"
self.db.tables["rides"][ride_id] = {"rider": rider, "bike": bike, "open": True}
return ride_id
def finish(self, ride_id: str) -> dict:
ride = self.db.tables["rides"][ride_id]
ride["open"] = False
return ride
# ---- Module: billing. ----
class Billing:
def __init__(self, db: Database) -> None:
self.db = db
@staticmethod
def price(plan: str, minutes: int) -> int:
if plan == "member":
return max(0, minutes - 30) * 10
return 100 + minutes * 10
def charge(self, rider: str, pence: int) -> None:
self.db.tables["charges"].setdefault(rider, []).append(pence)
# ---- The application: one process, one deployable, modules called in-process. ----
class Pedal:
def __init__(self) -> None:
self.db = Database()
self.docks, self.rides, self.billing = Docks(self.db), Rides(self.db), Billing(self.db)
self.plans = {"Mia": "member", "Leo": "payg"}
def unlock(self, rider: str, dock: str) -> str:
with self.db.transaction():
bike = self.docks.release(dock)
ride_id = self.rides.start(rider, bike)
print(f"{rider} unlocked bike {bike} at {dock}")
return ride_id
def give_back(self, ride_id: str, dock: str, minutes: int) -> None:
rider = self.db.tables["rides"][ride_id]["rider"]
try:
with self.db.transaction(): # three modules, one transaction
ride = self.rides.finish(ride_id)
self.docks.lock(dock, ride["bike"])
pence = self.billing.price(self.plans[rider], minutes)
self.billing.charge(rider, pence)
except DockFull:
print(f"{rider} tried {dock}: full, nothing changed (ride still open: {self.db.tables['rides'][ride_id]['open']})")
return
print(f"{rider} returned {ride['bike']} at {dock} after {minutes} min: £{pence // 100}.{pence % 100:02d}")
if __name__ == "__main__":
app = Pedal()
app.docks.add("Dock 12", ["B7", "B9"], slots=10)
app.docks.add("Dock 40", [], slots=1)
app.docks.add("Dock 41", [], slots=10)
mia = app.unlock("Mia", "Dock 12")
app.give_back(mia, "Dock 40", 42)
leo = app.unlock("Leo", "Dock 12")
app.give_back(leo, "Dock 40", 15)
app.give_back(leo, "Dock 41", 15)
print("modules: docks, rides, billing; deployable units: 1")
class DockFull extends Error {}
interface Tables {
docks: Record<string, { bikes: string[]; slots: number }>;
rides: Record<string, { rider: string; bike: string; open: boolean }>;
charges: Record<string, number[]>;
}
// One database for the whole app. Each module owns its own tables in it.
class Database {
tables: Tables = { docks: {}, rides: {}, charges: {} };
transaction(work: () => void): void {
const before: Tables = JSON.parse(JSON.stringify(this.tables));
try { work(); } catch (e) { this.tables = before; throw e; } // all or nothing, across every module's tables
}
}
// ---- Module: docks. Other modules use only these methods, never the "docks" table. ----
class Docks {
constructor(private db: Database) {}
add(dock: string, bikes: string[], slots: number): void { this.db.tables.docks[dock] = { bikes: [...bikes], slots }; }
release(dock: string): string { return this.db.tables.docks[dock].bikes.shift() as string; }
lock(dock: string, bike: string): void {
const d = this.db.tables.docks[dock];
if (d.bikes.length >= d.slots) throw new DockFull(dock);
d.bikes.push(bike);
}
}
// ---- Module: rides. ----
class Rides {
constructor(private db: Database) {}
start(rider: string, bike: string): string {
const id = `R${Object.keys(this.db.tables.rides).length + 1}`;
this.db.tables.rides[id] = { rider, bike, open: true };
return id;
}
finish(id: string): { rider: string; bike: string; open: boolean } { const r = this.db.tables.rides[id]; r.open = false; return r; }
}
// ---- Module: billing. ----
class Billing {
constructor(private db: Database) {}
static price(plan: string, minutes: number): number { return plan === "member" ? Math.max(0, minutes - 30) * 10 : 100 + minutes * 10; }
charge(rider: string, pence: number): void { (this.db.tables.charges[rider] ??= []).push(pence); }
}
// ---- The application: one process, one deployable, modules called in-process. ----
class Pedal {
db = new Database();
docks = new Docks(this.db); rides = new Rides(this.db); billing = new Billing(this.db);
plans: Record<string, string> = { Mia: "member", Leo: "payg" };
unlock(rider: string, dock: string): string {
let bike = "", id = "";
this.db.transaction(() => { bike = this.docks.release(dock); id = this.rides.start(rider, bike); });
console.log(`${rider} unlocked bike ${bike} at ${dock}`);
return id;
}
giveBack(id: string, dock: string, minutes: number): void {
const rider = this.db.tables.rides[id].rider;
let bike = "", pence = 0;
try {
this.db.transaction(() => { // three modules, one transaction
bike = this.rides.finish(id).bike;
this.docks.lock(dock, bike);
pence = Billing.price(this.plans[rider], minutes);
this.billing.charge(rider, pence);
});
} catch (e) {
if (!(e instanceof DockFull)) throw e;
console.log(`${rider} tried ${dock}: full, nothing changed (ride still open: ${this.db.tables.rides[id].open ? "True" : "False"})`);
return;
}
console.log(`${rider} returned ${bike} at ${dock} after ${minutes} min: £${Math.floor(pence / 100)}.${String(pence % 100).padStart(2, "0")}`);
}
}
const app = new Pedal();
app.docks.add("Dock 12", ["B7", "B9"], 10);
app.docks.add("Dock 40", [], 1);
app.docks.add("Dock 41", [], 10);
const mia = app.unlock("Mia", "Dock 12");
app.giveBack(mia, "Dock 40", 42);
const leo = app.unlock("Leo", "Dock 12");
app.giveBack(leo, "Dock 40", 15);
app.giveBack(leo, "Dock 41", 15);
console.log("modules: docks, rides, billing; deployable units: 1");import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
class DockFull extends RuntimeException {
private static final long serialVersionUID = 1L;
DockFull(String dock) { super(dock); }
}
class Dock { List<String> bikes; int slots; Dock(List<String> b, int s) { bikes = new ArrayList<>(b); slots = s; } Dock copy() { return new Dock(bikes, slots); } }
class Ride { String rider, bike; boolean open; Ride(String r, String b, boolean o) { rider = r; bike = b; open = o; } Ride copy() { return new Ride(rider, bike, open); } }
// One database for the whole app. Each module owns its own tables in it.
class Database {
Map<String, Dock> docks = new HashMap<>();
Map<String, Ride> rides = new LinkedHashMap<>();
Map<String, List<Integer>> charges = new HashMap<>();
void transaction(Runnable work) {
Map<String, Dock> d0 = new HashMap<>(); docks.forEach((k, v) -> d0.put(k, v.copy()));
Map<String, Ride> r0 = new LinkedHashMap<>(); rides.forEach((k, v) -> r0.put(k, v.copy()));
Map<String, List<Integer>> c0 = new HashMap<>(); charges.forEach((k, v) -> c0.put(k, new ArrayList<>(v)));
try { work.run(); }
catch (RuntimeException e) { docks = d0; rides = r0; charges = c0; throw e; } // all or nothing, across every module's tables
}
}
// ---- Module: docks. Other modules use only these methods, never the docks table. ----
class Docks {
private final Database db;
Docks(Database db) { this.db = db; }
void add(String dock, List<String> bikes, int slots) { db.docks.put(dock, new Dock(bikes, slots)); }
String release(String dock) { return db.docks.get(dock).bikes.remove(0); }
void lock(String dock, String bike) {
Dock d = db.docks.get(dock);
if (d.bikes.size() >= d.slots) throw new DockFull(dock);
d.bikes.add(bike);
}
}
// ---- Module: rides. ----
class Rides {
private final Database db;
Rides(Database db) { this.db = db; }
String start(String rider, String bike) { String id = "R" + (db.rides.size() + 1); db.rides.put(id, new Ride(rider, bike, true)); return id; }
Ride finish(String id) { Ride r = db.rides.get(id); r.open = false; return r; }
}
// ---- Module: billing. ----
class Billing {
private final Database db;
Billing(Database db) { this.db = db; }
static int price(String plan, int minutes) { return plan.equals("member") ? Math.max(0, minutes - 30) * 10 : 100 + minutes * 10; }
void charge(String rider, int pence) { db.charges.computeIfAbsent(rider, k -> new ArrayList<>()).add(pence); }
}
// ---- The application: one process, one deployable, modules called in-process. ----
public class Main {
final Database db = new Database();
final Docks docks = new Docks(db);
final Rides rides = new Rides(db);
final Billing billing = new Billing(db);
final Map<String, String> plans = Map.of("Mia", "member", "Leo", "payg");
String unlock(String rider, String dock) {
String[] out = new String[2];
db.transaction(() -> { out[0] = docks.release(dock); out[1] = rides.start(rider, out[0]); });
System.out.println(rider + " unlocked bike " + out[0] + " at " + dock);
return out[1];
}
void giveBack(String id, String dock, int minutes) {
String rider = db.rides.get(id).rider;
String[] bike = new String[1];
int[] pence = new int[1];
try {
db.transaction(() -> { // three modules, one transaction
bike[0] = rides.finish(id).bike;
docks.lock(dock, bike[0]);
pence[0] = Billing.price(plans.get(rider), minutes);
billing.charge(rider, pence[0]);
});
} catch (DockFull e) {
System.out.println(rider + " tried " + dock + ": full, nothing changed (ride still open: " + (db.rides.get(id).open ? "True" : "False") + ")");
return;
}
System.out.println(rider + " returned " + bike[0] + " at " + dock + " after " + minutes + " min: £" + (pence[0] / 100) + "." + String.format("%02d", pence[0] % 100));
}
public static void main(String[] args) {
Main app = new Main();
app.docks.add("Dock 12", List.of("B7", "B9"), 10);
app.docks.add("Dock 40", List.of(), 1);
app.docks.add("Dock 41", List.of(), 10);
String mia = app.unlock("Mia", "Dock 12");
app.giveBack(mia, "Dock 40", 42);
String leo = app.unlock("Leo", "Dock 12");
app.giveBack(leo, "Dock 40", 15);
app.giveBack(leo, "Dock 41", 15);
System.out.println("modules: docks, rides, billing; deployable units: 1");
}
}Output (the same in all three languages)
Mia unlocked bike B7 at Dock 12 Mia returned B7 at Dock 40 after 42 min: £1.20 Leo unlocked bike B9 at Dock 12 Leo tried Dock 40: full, nothing changed (ride still open: True) Leo returned B9 at Dock 41 after 15 min: £2.50 modules: docks, rides, billing; deployable units: 1
6. Try it
The deploy queue
The cost that usually ends a monolith isn't performance; it's teams waiting for each other to release. Every team shares one pipeline. Set the numbers and watch the wait.
Model: releases arrive at random through an 8-hour working day and go through one pipeline one at a time (a queue with fixed service time). Real pipelines batch releases and run checks in parallel; the shape of the curve is what matters.
7. What it costs, and when to pick another style
What you get
- Simple to run: one thing to build, deploy, monitor and debug.
- Real transactions: a whole flow is all-or-nothing in one database.
- Fast calls: function calls, not network hops that can fail.
- Easy refactoring: moving code between modules is an IDE operation.
What you pay
- Shared release train: every team ships at the pace of the slowest test suite.
- Scale as one: the whole app is copied to scale its busiest part.
- One technology stack: a part that would suit another language or runtime can't have it.
- One failure domain: a memory leak in reports can take down unlocking.
| If… | Consider instead |
|---|---|
| Many teams need to release independently | Microservices or service-based |
| One part has very different load or uptime needs | Extract just that part as a service |
| Business rules must outlive the framework | Keep the monolith, organise it hexagonally |
| Many customers or cities need different behaviour | Microkernel inside the monolith |
8. Signs you've outgrown it
- Teams wait hours (or days) for a release slot, and releases bundle unrelated changes.
- Merge conflicts and broken builds come mostly from other teams' code.
- One part needs ten times the servers of the rest, or must stay up when the rest is down.
- Nobody can say which modules a change touches; boundary checks are routinely bypassed.
- Start-up or test time has grown past what a developer will wait for.
9. Remember it and say it
How this hook works: what “One kitchen, walled stations” means
What the slogan means, phrase by phrase
- “One kitchen”
- One deployable unit, one process, usually one database.
- “walled stations”
- Modules with public interfaces and their own tables, checked by the build.
Why this picture fits: A restaurant kitchen: shared space and one fridge, but the grill, pastry and prep stations each keep their own tools.
The story → the concept
| The kitchen | Pedal |
|---|---|
| The building | One deployable application |
| Stations | Docks, rides and billing modules |
| Each station's shelf | Each module's own tables |
| The pass | Public interfaces between modules |
Watch it on an example
- A return is one transaction across three modules.
- Billing never reads the rides table; it's handed what it needs.
- If billing ever has to become a service, its data and interface are already separate.
The trap it saves you from
Calling a codebase with no boundaries a "modular monolith": the modules exist only in folder names.
It still works when…
When you do split: the modules become the services, along walls you've already tested.
In an interview or design review
- Start with a monolith and say why: "One team, a new product: a modular monolith gets us real transactions and one thing to operate. Modules own their tables, so any of them can be extracted later."
- Name the trigger for splitting: independent release cadence, very different scaling, or a separate failure domain, not "it got big".
- Distinguish the two monoliths: modular (walls, enforced) vs big ball of mud (none).
- Mention the strangler fig as the way out: route one capability at a time to a new service (Architecture module 30).
10. Check yourself
What makes a monolith "modular"?
Pedal's return closes the ride, locks the bike and charges the card. What does a monolith make easy here?
Which is the strongest reason to extract a service from a monolith?
Six teams share one 45-minute pipeline and wait hours to release. What do you try first?
11. Go deeper
| Resource | Why |
|---|---|
| Architecture 11: Layered and modular monolith | How an architect reasons about this style. |
| Case study: Shopify's modular monolith | A large company choosing modules over services. |
| Monolith First (Martin Fowler) | Why most successful service architectures started as monoliths. |
| Spring Modulith and import-linter | Tools that check module boundaries in Java and Python builds. |