Skip to content
Episode 31 · Walls inside one building

The modular monolith

Tuesday, 11:15: a dependency scan finds 412 imports crossing team lines, and Search runs 40 queries straight on Booking's tables. Booking wants to change one column, and it would break all 40.

One app, one deploy, but with locked doors between its parts. You get most of the benefits of microservices for a fraction of the cost.

30 min intermediateModular monolithEnforced module boundaries and data ownershipExtracting a module into a serviceCoupling & cohesionMonolithMicroservices
  1. Problem

    A growing team shares one codebase. Every change touches everything, nobody knows who owns what, and a 'quick fix' in billing breaks bookings.

  2. Why the simple system fails

    A plain monolith decays into a big ball of mud because nothing stops one part from reaching into another's internals and tables. Jumping to microservices adds network failures and ops cost without fixing unclear boundaries.

  3. Engineering concept

    Split the codebase into modules aligned with business domains. Each module owns its data and exposes a small public API; tooling makes crossing a boundary a build error. Keep one deployable.

  4. Technology

    Module folders with public index files, lint/architecture rules (dependency-cruiser, ESLint boundaries, ArchUnit, Packwerk-style tools), schema per module, in-process interfaces and domain events, outbox for later extraction.

  5. Trade-offs

    You keep cheap function calls, one transaction and one pipeline, but give up independent deploys and per-module scaling, and boundaries only hold if they are enforced.

01

The problem

2 min
Tuesday, 11:15. Lina shares a dependency scan of the monolith.
  1. Lina · Senior engineer, your mentor
    412 imports cross from one team's area into another's. And Search runs 40 queries straight on Booking's tables.
  2. Maya · Product manager
    Booking wants to split bookings.status into two columns this sprint.
  3. You · Engineer at StayHub
    So that breaks 40 Search queries. How do we stop this happening every time?
02

The big idea

4 min
Forty queries on someone else's table
The big idea

A is one deployable app with locked walls inside: each module owns its code and data, and others come in only through its front door.

One hotel building with locked department offices: Housekeeping can't edit Accounting's ledger, it asks at the window. Walking between offices still takes seconds, unlike sending a courier across town ().

03

How it works

5 min

Fixing the 40 queries one by one only fixes today; next month someone writes query 41. So each business area, a , becomes a module: a folder with its own code, rules and data. Here's a search request once the walls are up.

A search request, with walls
  1. 1
    Enter through a route

    A thin HTTP route calls search.results(). It knows module APIs, not internals.

  2. 2
    Search asks, doesn't grab

    Search calls inventory.availability(hotelIds, nights), part of Inventory's public API: the few functions a module exports. A function call, no network.

  3. 3
    Inventory reads its own data

    Data ownership: only Inventory touches the inventory schema, with its own database role.

  4. 4
    Others react to events

    When a booking is confirmed, Booking publishes BookingConfirmed; Inventory updates its counts.

  5. 5
    The build guards the walls

    A boundary check in CI fails an import into another module's internal/ folder. A query on another schema fails in PostgreSQL.

04

Watch a decision being made

4 min
Worked example

You carve out the Inventory module (Lina checks)

Search runs 40 queries on Booking's tables to know which rooms are free. Give availability one owner and one front door, without stopping feature work or breaking search.

  1. 1Map the real questions

    Group the 40 queries by what they ask: 31 want "free rooms for these hotels and nights", 9 want booking counts for ranking.

    Why: You design the API from real questions. Most of the 40 are one question asked 31 ways.

  2. 2Name the owner by capability, design a small API

    Availability is neither Booking's nor Search's: it's Inventory.

    ts
    // src/modules/inventory/index.ts
    export interface InventoryApi {
      availability(hotelIds: number[], nights: string[]): Promise<Map<number, number>>;
      holdRooms(input: HoldInput): Promise<HoldId>;
    }

    Why: Modules drawn around capabilities stay stable; modules around nouns ("Hotel") become everyone's dependency. A narrow API is easy to keep stable.

  3. 3Move the data behind it

    Availability tables go into an inventory schema, updated from BookingConfirmed and BookingCancelled. The 9 ranking queries get Search's own booking_counts table, fed by the same events.

    Why: Ownership exists only when one module writes the data. Search keeps a copy shaped for its question, a few seconds behind, fine for ranking.

  4. 4Turn on the check, with a baseline

    The CI rule freezes today's 412 crossings: no new ones allowed. Teams remove a few each sprint; database grants come last.

    Why: Failing all 412 on day one would block every merge. A ratchet only gets better.

The whole thing in one line: Group the queries into real questions, give the capability an owner with a two-function API, move its data behind a schema and events, and lock it with a CI ratchet.
05

In real life

3 min
Boundaries by build, or by network

Shopify: a modular monolith

  • Has publicly described one deployable Rails app split into components
  • Released Packwerk, an open-source tool that checks boundaries between packages

Boundaries without the network, at very large team size.

Uber: thousands of services, then domains

  • Has publicly described growing to thousands of microservices
  • Later grouped them into larger domains because the web got hard to manage

Network boundaries everywhere, then grouped back.

06

What goes wrong

3 min
Cross-module joins in SQL

They couple you to private tables and break silently.

Instead

Use the owner's API, or your own table fed by its events.

Modules by technical layer (controllers/, services/)

Every feature touches every folder; nothing a team can own.

Instead

Top-level folders by business capability.

Boundaries by convention only

"Just this once" imports, and a year later the walls are gone.

Instead

Fail the build on forbidden imports and cycles; one database role per module.

07

Trade-offs

3 min
OptionFits whenYou pay
Plain monolithA small team finding product fitDecays into mud as the team grows
Modular monolithSeveral teams, one product, no need for separate scalingShared release train; tooling for the walls
MicroservicesMany teams blocked by one pipeline, different scaling needsNetwork failures; wrong cuts are very costly
Wednesday, 15:00. Partners want a revenue dashboard by Friday.
  1. Maya · Product manager
    One SQL join across Payments, Booking and Inventory does it in a day.
  2. Lina · Senior engineer, your mentor
    That's exactly the join that underpaid 140 hotels. Reports gets its own event-fed table: about 3 more days.
  3. Maya · Product manager
    Three days late on a promise to 1,800 hotels is a real cost too.
08

Your job as tech lead

4 min

Claude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.

Decisions you own
  • Where the walls go, drawn by capability (who decides availability? Inventory), not by noun or folder type.
  • Who owns which tables, and how others get data: call the API, or keep their own copy fed by events.
  • How strict the check is on day one: a frozen baseline of 412 crossings, no new ones; and when a deadline justifies a written exception.
Tell Claude Code
Search runs about 40 SQL queries directly on Booking's tables to find free rooms. I want an Inventory module in src/modules/inventory that owns availability. Plan first: group the 40 queries by the question they answer, then propose a public API of at most three functions in index.ts, with counts updated from BookingConfirmed and BookingCancelled. Don't change search results or move tables yet. Add a dependency-cruiser rule forbidding imports of another module's internal folder, with today's crossings as a baseline.
Check before you accept
  • The proposed API is small. Fifteen functions or exposed table rows means a copy of the internals, not a front door.
  • The boundary check fails on a deliberate bad import, then passes without it.
  • Search results are identical before and after for the same hotels and nights.
Red flags
  • It adds an exception or an eslint-disable to make the boundary check pass.
  • A new report joins payments.charges with another module's tables.
  • It proposes making Inventory a service while the module boundary doesn't exist yet.
09

Check yourself

2 min
Q1/3Reasoning
Which description best captures a modular monolith?
How confident are you?

Next episode: after every booking, Booking's code calls email, loyalty, invoicing and the hotel's channel manager in a row, and checkout takes 2.8 seconds.

Finished?

Mark it complete to update your skill map.

Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Interactive simulation, Visual diagram, Analogy, Why it matters, Check your understanding, Practice ladder, Explain it yourself.
10

Technical deep dive

Making the wall real

This time you drive and Lina checks. The task is the 40 Search queries, but the method works for any tangled area.

Worked example

Carving out the Inventory module

Search runs 40 queries on Booking's tables to know which rooms are free. Give availability one owner and one front door, without stopping feature work or breaking search.

  1. 1Map the real dependencies

    Run a dependency tool over the code and list every SQL query by the tables it touches. Group the 40 Search queries by what they ask: 31 want "free rooms for these hotels and nights", 9 want booking counts for ranking.

    Why: You design the API from real questions. Most of the 40 turn out to be the same question asked 31 ways.

  2. 2Name the owner by capability

    Availability isn't really Booking's or Search's: it's Inventory. Create modules/inventory owned by the Booking team for now.

    Why: Modules drawn around capabilities (who decides availability?) stay stable; modules drawn around nouns ("Hotel") become everyone's dependency.

  3. 3Design a small public API

    Export only what callers need.

    ts
    // src/modules/inventory/index.ts
    export interface InventoryApi {
      availability(hotelIds: number[], nights: string[]): Promise<Map<number, number>>;
      holdRooms(input: HoldInput): Promise<HoldId>;
    }

    Why: A narrow API is easy to keep stable, and easy to put behind an HTTP client if Inventory ever becomes a service.

  4. 4Move the data behind it

    Move availability tables into an inventory schema with its own database role. Booking publishes BookingConfirmed and BookingCancelled; Inventory updates its counts. The 9 ranking queries get a small search.booking_counts table, fed by the same events.

    Why: Ownership only exists when one module writes the data. Search keeps a copy shaped for its own question instead of joining someone else's tables.

  5. 5Turn on the check, with a baseline

    Add the dependency rule in CI with today's 412 crossings as a frozen baseline: no new crossings allowed. Each sprint, teams remove a few. The database grants come last, once a module's crossings are at zero.

    Why: Failing all 412 on day one would block every merge. A ratchet makes things only get better.

The whole thing in one line: You mapped the 40 queries to two real questions, gave availability to an Inventory module with a two-function API, moved its data behind a schema and events, gave Search its own ranking copy, and locked it with a CI ratchet.
json
{
  "forbidden": [
    {
      "name": "no-reaching-into-other-modules",
      "severity": "error",
      "from": { "path": "^src/modules/([^/]+)/" },
      "to":   { "path": "^src/modules/([^/]+)/internal/", "pathNot": "^src/modules/$1/" }
    },
    { "name": "no-cycles", "severity": "error", "from": {}, "to": { "circular": true } }
  ]
}
dependency-cruiser excerpt: no reaching into another module's internals, no cycles. Similar tools exist in most languages.
Module interactionHowWhen to useWatch out
Synchronous callCall the other module's public functionYou need an answer now (hold rooms, get price)Cycles: A calls B calls A means a missing module
In-process eventPublish an event; subscribers reactSide effects others care about (emails, loyalty points)Decide: same transaction or after commit
Outbox eventWrite the event row in the same transaction; a relay delivers itReactions must survive crashes, or the module may be extractedConsumers must handle duplicates
Shared kernelTiny shared package (Money, ids)Truly universal value typesGrows into a dumping ground if not policed
11

Interactive simulation

12

Visual diagram

Click a component

One deployable for a hotel booking product. Modules own their data and talk only through public APIs or in-process events.

13

Analogy

  • In a hotel buildingOne buildingIn a modular monolithOne deployable app
  • In a hotel buildingLocked department officesIn a modular monolithModules with private insides
  • In a hotel buildingThe reception windowIn a modular monolithThe module's public API
  • In a hotel buildingEach office keeps its own recordsIn a modular monolithEach module owns its tables
  • In a hotel buildingLaundry moves to its own siteIn a modular monolithExtracting a module into a service

Where the analogy breaks: a locked office door stops people for real. In code, the walls only hold if a tool checks them on every build.

14

Why it matters

Most products never need dozens of services. But almost every product past a handful of engineers needs boundaries.

Without them, a 25-person team lives in merge conflicts and "who changed this?" meetings.

Splitting into services too early freezes your boundaries when you understand the business least. Every wrong cut becomes a network call that can time out.

In a modular monolith, a wrong boundary is a quick refactor. And a clean module can later become its own service in weeks, not years.

Three ways to organise the same app

Big ball of mud

  • Anyone touches anything
  • Fast at first
  • Chaos at 50 people

Hard to change

Modular monolith

  • Locked offices, one building
  • Calls take no time
  • Walls easy to move

Great default

Microservices

  • Separate buildings
  • Every call can fail
  • Walls hard to move

When teams truly need it

15

Check your understanding

Q1/3Design
Put the steps for extracting the Payments module into a service in a sensible order:
  1. 1.Move the payments schema to its own database and cut over
  2. 2.Move reactions to outbox events and make commands idempotent
  3. 3.Give it its own pipeline, dashboards, alerts and on-call
  4. 4.Swap the in-process implementation for an HTTP client with timeouts and retries
  5. 5.Confirm the module's public API is stable and no one touches its tables
How confident are you?
16

Practice ladder

Step 1 · Trace

Which imports fail the build?

Code Warm-up

StayHub's CI now runs this rule: a file in `src/modules/X/` may not import from `src/modules/Y/internal/` when X ≠ Y, and no import cycles.

For each line, say whether the build passes or fails, and why:

  1. src/modules/search/internal/rank.ts imports @/modules/inventory
  2. src/modules/search/internal/rank.ts imports @/modules/inventory/internal/room-repo
  3. src/modules/booking/internal/cancel.ts imports @/modules/booking/internal/policy
  4. src/modules/payments/index.ts imports @/modules/booking, and src/modules/booking/index.ts already imports @/modules/payments
Step 2 · Complete

Finish the plan for the Payments module

Design Practice

Payments code is spread across Booking, Reports and the payout job. Lina started the plan to make it a real module; two steps are blank.

  1. Map the real dependencies: 23 files call the payment SDK; 6 SQL queries outside Payments read charges.
  2. Name the owner: the Booking team for now; a Payments module.
  3. Design a small public API: ___
  4. Move the data behind it: ___
  5. Turn on the check with a baseline, then add database grants once crossings reach zero.

Fill in steps 3 and 4, and say why.

Step 3 · Solve

Where do guest reviews go?

Design Practice

Maya wants guest reviews: after checkout, a guest can rate the stay and write a comment. Reviews show on hotel pages and affect search ranking. Only guests with a completed stay may review, once per booking. Hotels can reply.

Design it as a module in StayHub's modular monolith: name it, list its public API and events, say which data it owns, how it learns that a stay was completed, and how Search uses review scores without crossing a wall.

Step 4 · Decide

Extract Payments now?

Decision Stretch

Three months later, Payments is a clean module: a 3-function API that hasn't changed in 8 weeks, its own schema, zero crossings. Karim asks: "Can we make Payments a separate service now? Auditors would like card-related code isolated."

Facts: still 14 engineers, no dedicated Payments team, one on-call rotation. Payments deploys with everything else, about 6 times a week. The auditors' request is a "nice to have" this year and likely required next year.

Write a decision: "We chose X over Y because…, accepting the risk of…", with a trigger for revisiting.

17

Explain it yourself

Your CTO asks: 'Why not go straight to microservices instead of a modular monolith?' Explain your recommendation for StayHub's 14 engineers in three teams (Search, Booking, Hotel Partners).

0 words