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.
- Problem
A growing team shares one codebase. Every change touches everything, nobody knows who owns what, and a 'quick fix' in billing breaks bookings.
- 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.
- 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.
- 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.
- 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.
The problem
2 min- Lina · Senior engineer, your mentor412 imports cross from one team's area into another's. And Search runs 40 queries straight on Booking's tables.
- Maya · Product managerBooking wants to split
bookings.statusinto two columns this sprint. - You · Engineer at StayHubSo that breaks 40 Search queries. How do we stop this happening every time?
The big idea
4 minA 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 ().
How it works
5 minFixing 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.
- 1Enter through a route
A thin HTTP route calls
search.results(). It knows module APIs, not internals. - 2Search 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. - 3Inventory reads its own data
Data ownership: only Inventory touches the
inventoryschema, with its own database role. - 4Others react to events
When a booking is confirmed, Booking publishes
BookingConfirmed; Inventory updates its counts. - 5The 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.
Watch a decision being made
4 minYou 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.
- 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.
- 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.
- 3Move the data behind it
Availability tables go into an
inventoryschema, updated fromBookingConfirmedandBookingCancelled. The 9 ranking queries get Search's ownbooking_countstable, 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.
- 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.
In real life
3 minShopify: 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.
What goes wrong
3 minThey couple you to private tables and break silently.
Use the owner's API, or your own table fed by its events.
Every feature touches every folder; nothing a team can own.
Top-level folders by business capability.
"Just this once" imports, and a year later the walls are gone.
Fail the build on forbidden imports and cycles; one database role per module.
Trade-offs
3 min| Option | Fits when | You pay |
|---|---|---|
| Plain monolith | A small team finding product fit | Decays into mud as the team grows |
| Modular monolith | Several teams, one product, no need for separate scaling | Shared release train; tooling for the walls |
| Microservices | Many teams blocked by one pipeline, different scaling needs | Network failures; wrong cuts are very costly |
- Maya · Product managerOne SQL join across Payments, Booking and Inventory does it in a day.
- Lina · Senior engineer, your mentorThat's exactly the join that underpaid 140 hotels. Reports gets its own event-fed table: about 3 more days.
- Maya · Product managerThree days late on a promise to 1,800 hotels is a real cost too.
Your job as tech lead
4 minClaude 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.
- 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.
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.
- 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.
- It adds an exception or an
eslint-disableto make the boundary check pass. - A new report joins
payments.chargeswith another module's tables. - It proposes making Inventory a service while the module boundary doesn't exist yet.
Check yourself
2 minNext 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.
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.
Technical deep dive
This time you drive and Lina checks. The task is the 40 Search queries, but the method works for any tangled area.
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.
- 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.
- 2Name the owner by capability
Availability isn't really Booking's or Search's: it's Inventory. Create
modules/inventoryowned 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.
- 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.
- 4Move the data behind it
Move availability tables into an
inventoryschema with its own database role. Booking publishesBookingConfirmedandBookingCancelled; Inventory updates its counts. The 9 ranking queries get a smallsearch.booking_countstable, 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.
- 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.
{
"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 } }
]
}| Module interaction | How | When to use | Watch out |
|---|---|---|---|
| Synchronous call | Call the other module's public function | You need an answer now (hold rooms, get price) | Cycles: A calls B calls A means a missing module |
| In-process event | Publish an event; subscribers react | Side effects others care about (emails, loyalty points) | Decide: same transaction or after commit |
| Outbox event | Write the event row in the same transaction; a relay delivers it | Reactions must survive crashes, or the module may be extracted | Consumers must handle duplicates |
| Shared kernel | Tiny shared package (Money, ids) | Truly universal value types | Grows into a dumping ground if not policed |
Interactive simulation
Visual diagram
One deployable for a hotel booking product. Modules own their data and talk only through public APIs or in-process events.
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.
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.
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
Check your understanding
- 1.Move the payments schema to its own database and cut over
- 2.Move reactions to outbox events and make commands idempotent
- 3.Give it its own pipeline, dashboards, alerts and on-call
- 4.Swap the in-process implementation for an HTTP client with timeouts and retries
- 5.Confirm the module's public API is stable and no one touches its tables
Practice ladder
Which imports fail the build?
Code Warm-upStayHub'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:
src/modules/search/internal/rank.tsimports@/modules/inventorysrc/modules/search/internal/rank.tsimports@/modules/inventory/internal/room-reposrc/modules/booking/internal/cancel.tsimports@/modules/booking/internal/policysrc/modules/payments/index.tsimports@/modules/booking, andsrc/modules/booking/index.tsalready imports@/modules/payments
Finish the plan for the Payments module
Design PracticePayments code is spread across Booking, Reports and the payout job. Lina started the plan to make it a real module; two steps are blank.
- Map the real dependencies: 23 files call the payment SDK; 6 SQL queries outside Payments read
charges. - Name the owner: the Booking team for now; a Payments module.
- Design a small public API: ___
- Move the data behind it: ___
- Turn on the check with a baseline, then add database grants once crossings reach zero.
Fill in steps 3 and 4, and say why.
Where do guest reviews go?
Design PracticeMaya 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.
Extract Payments now?
Decision StretchThree 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.
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).