InterviewKata
Architecture styles · 01 of 16 · Inside one application

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 small restaurant has one kitchen. The cook, the baker and the person on the grill share the space, call across to each other, and use one fridge. It's fast and cheap to run, and a new dish can be added in an afternoon. It works until forty cooks are tripping over each other, or the grill is overloaded while the pastry corner sits idle, and you can't build a second grill without building a second kitchen.

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

RuleWhy it existsBroken when…
One deployable unitOne 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 interfacesA module's internals can change without touching the others.Billing reads rides columns directly "because it's quicker".
Each module owns its tablesData 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 buildRules 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

The big ball of mudNo modules, so every change can break anything and nobody can say what a feature touches.
Splitting too earlyMoving to services before the boundaries are known: the walls end up in the wrong places, now with a network between them.
Scaling everything for one hot partRunning twenty copies of the whole app because only the unlock endpoint is busy at 8 a.m.
One slow pipeline for everyoneA 50-minute build and test run that every team queues behind; fix the pipeline before blaming the architecture.

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.

Teams
Releases per team per day
Build, test and deploy time

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 independentlyMicroservices or service-based
One part has very different load or uptime needsExtract just that part as a service
Business rules must outlive the frameworkKeep the monolith, organise it hexagonally
Many customers or cities need different behaviourMicrokernel inside the monolith

8. Signs you've outgrown it

9. Remember it and say it

Memory hook: "One kitchen, walled stations"One deployable is fine; one undivided codebase isn't. Keep the single kitchen, but give each station its own space.
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 kitchenPedal
The buildingOne deployable application
StationsDocks, rides and billing modules
Each station's shelfEach module's own tables
The passPublic interfaces between modules

Watch it on an example

  1. A return is one transaction across three modules.
  2. Billing never reads the rides table; it's handed what it needs.
  3. 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

10. Check yourself

What makes a monolith "modular"?

Folders aren't boundaries unless something checks them.

Pedal's return closes the ride, locks the bike and charges the card. What does a monolith make easy here?

All-or-nothing without sagas or compensations.

Which is the strongest reason to extract a service from a monolith?

Split for a measurable force, not for size alone.

Six teams share one 45-minute pipeline and wait hours to release. What do you try first?

Fix the cheap cause first; split if waits stay high after that.

11. Go deeper

ResourceWhy
Architecture 11: Layered and modular monolithHow an architect reasons about this style.
Case study: Shopify's modular monolithA large company choosing modules over services.
Monolith First (Martin Fowler)Why most successful service architectures started as monoliths.
Spring Modulith and import-linterTools that check module boundaries in Java and Python builds.