Session 4, third part · Week 2 · Thu 12 Nov 2026

Architectural Styles: Monolith to Microservices.

Last week we faced the failures that come with more than one machine. Tonight, the bigger question: how many machines should you have in the first place? The whole spectrum, what each style buys, what it charges, and how to choose.

Ehsan Gazar
From Senior to Staff · session 4 of 8
Where we're going · 90 minutes

The run sheet.

0–12It is a spectrumcoupling, cohesion, the one big trade
12–32Monolith & modular monoliththe underrated default
32–55Microserviceswhat they buy, what they cost
55–72When to splitthe real axes & Conway's law
72–85Event-driven & serverlessdecoupling in time
85–90The trap + exercisethe distributed monolith
By the end of tonight

You'll be able to…

1Place any system on the monolith ↔ microservices ↔ event-driven spectrum.
2Reach for the modular monolith as the default, and enforce its boundaries.
3Name the real axis that justifies a split, and the cost you take on.
4Recognise and avoid the distributed monolith.
The one big idea

Couple by default. Earn every split.

Microservices are not a maturity level you graduate to. They are a trade: you buy independent deploy and scale, and you pay in network calls, partial failure, and scattered data. Most teams should start coupled and split only on a real seam.

💡 In plain English

One big house versus a village of cabins. Cabins can be renovated one at a time, but now you walk outside in the rain to get from the kitchen to the bedroom. Build the big house with tidy rooms first; move a room into its own cabin only when there is a real reason.

Modular monolith
clear modules, one deploy: most of the benefit, little of the cost
default
Split on a real axis
scaling, change rate, or failure boundary that genuinely differs
earn it
Distributed monolith
services that must deploy together: all cost, no gain
avoid
It is a spectrum · 0–12 min

Not monolith versus microservices. A dial.

The question is never "monolith or microservices." It is "how far along this line does my system need to be?" You can sit anywhere, and you can move along it over time. Every step right buys independence and charges complexity.

flowchart LR
  A["Monolith
one deployable"] --> B["Modular monolith
boundaries inside"] B --> C["Microservices
independent services"] C --> D["Event-driven
decoupled in time"] classDef a fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef b fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef c fill:#DCEBFE,stroke:#1F2937,color:#0E1726; classDef d fill:#EDE9FE,stroke:#1F2937,color:#0E1726; class A a; class B b; class C c; class D d;
💡 In plain English

Think of a volume knob, not a light switch. The skill is not picking a side, it is choosing how far to turn the knob for this system, this team, this year, and being able to say why.

The lens for the whole night

Two words decide everything: coupling and cohesion.

Every style on the spectrum is just a different answer to these two. Cohesion: do the things that change together live together? Coupling: how much does one part need to know about another? You want high cohesion inside a boundary and low coupling across it.

High cohesion
things that change for the same reason sit in one place
want
Low coupling
a boundary you can change behind without breaking others
want
The failure
low cohesion + high coupling = change one thing, touch ten
avoid
💡 In plain English

Cohesion is keeping all the kitchen things in the kitchen. Coupling is how often you must run to another room to cook. Good architecture, at any size, is high cohesion and low coupling. The styles are just where you draw the walls.

Monolith & modular monolith · 12–32 min

The monolith: one deployable, one process.

All your code ships as a single unit, runs in one process, and usually talks to one database. This is not a dirty word. It is the simplest thing that works, and for most products it is the right starting point.

flowchart TB
  subgraph ONE["one deployable · one process"]
    UI["web / API layer"] --> SVC["business logic"]
    SVC --> DATA["data access"]
  end
  DATA --> DB[("one shared database")]
  classDef box fill:#FEF3C7,stroke:#1F2937,color:#0E1726;
  classDef db fill:#DCEBFE,stroke:#1F2937,color:#0E1726;
  class UI,SVC,DATA box;
  class DB db;
        
💡 In plain English

One building, with floors. Everything is under one roof, so walking between rooms is instant and free. The downside only shows up later, when the building gets too big for one team to share.

The monolith, honestly

Cheap and coherent, until it is crowded.

What it gives you
one deploy · in-process calls (fast, reliable) · easy database transactions · one place to debug · trivial to run
strengths
What it costs you
scaling is all-or-nothing · one deploy pipeline for everyone · a bad change can take it all down · many teams contend in one codebase
strains
💡 In plain English

None of the costs hit on day one. They creep in as the codebase and the team grow. That is the whole story of this session: the monolith is right until specific, nameable pains appear.

The real pains, named

Four pressures push a monolith apart.

Teams do not leave the monolith because microservices are fashionable. They leave when one of these four genuinely hurts. If none of them hurt, you do not have a reason to split yet.

flowchart LR
  M["the monolith grows"] --> P1["deploy coupling
one small change ships everything"] M --> P2["scaling coupling
scale the whole app for one hot path"] M --> P3["blast radius
one bug can take it all down"] M --> P4["team friction
many teams, one shared codebase"] classDef m fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef bad fill:#FEE4E2,stroke:#1F2937,color:#0E1726; class M m; class P1,P2,P3,P4 bad;
💡 In plain English

These are the four sentences that justify a split later. Memorise them. "We split billing off because it scales differently and a bug in it must not take down checkout" is a real reason. "We wanted microservices" is not.

The underrated default

The modular monolith: walls inside, one front door.

Keep the single deployable, but split the inside into real modules with enforced boundaries: each owns its data and exposes a clear internal API. You get most of the clarity of microservices with almost none of the distributed cost.

flowchart TB
  subgraph PROC["one deployable · one process"]
    A["Orders
module"] -->|"internal API"| B["Billing
module"] B -->|"internal API"| C["Catalog
module"] end A --> DBA[("orders tables")] B --> DBB[("billing tables")] C --> DBC[("catalog tables")] classDef mod fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef db fill:#DCEBFE,stroke:#1F2937,color:#0E1726; class A,B,C mod; class DBA,DBB,DBC db;
💡 In plain English

Still one building, but now the rooms have real walls and doors instead of an open-plan mess. If you ever do need to move a room into its own cabin later, the wall is already there to cut along.

Walls that actually hold

A module boundary is only real if it is enforced.

"We have modules" means nothing if any code can reach into any table. The boundary has to be enforced by structure, not by good intentions, or it rots back into a big ball of mud.

Own your data
each module owns its tables; no other module reads them directly, only via its API
rule
Talk through a façade
a module exposes a small public interface; everything else inside is private
rule
Enforce in the build
package structure, module systems, or lint rules that fail the build on a reach-in
rule
No shared mutable tables
the moment two modules write the same table, the wall is gone
rule
Microservices · 32–55 min

Microservices: many deployables, each owning its data.

Now each module becomes its own service: its own process, its own database, deployed and scaled on its own schedule, talking to the others over the network. The walls became roads.

flowchart TB
  GW["API gateway"] --> S1["Orders service"]
  GW --> S2["Billing service"]
  GW --> S3["Catalog service"]
  S1 -->|"network call"| S2
  S1 --> D1[("orders DB")]
  S2 --> D2[("billing DB")]
  S3 --> D3[("catalog DB")]
  classDef gw fill:#FEF3C7,stroke:#1F2937,color:#0E1726;
  classDef svc fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
  classDef db fill:#DCEBFE,stroke:#1F2937,color:#0E1726;
  class GW gw;
  class S1,S2,S3 svc;
  class D1,D2,D3 db;
        
💡 In plain English

The rooms moved into separate cabins, each with its own lock and its own fridge. Independence, yes, but every trip between them is now a walk across the yard, and the yard has weather.

The real benefit

What microservices actually buy you.

1
Independent deploy
ship billing without redeploying checkout
2
Independent scale
give the hot service more boxes, leave the rest small
3
Fault isolation
one service down need not take the others with it
4
Team autonomy
a team owns a service end to end, ships on its own clock
5
Tech choice
the right language or store per service, within reason
6
Clear ownership
a hard boundary makes "who owns this" obvious
💡 In plain English

Notice every benefit is about independence: deploy, scale, fail, choose, own, on your own schedule. If your teams do not actually need that independence yet, you are buying something you will not use.

The bill, in full

Everything you put on the network, you now pay for.

The split turns fast, reliable in-process calls into slow, fallible network calls. Every problem from the last two sessions, timeouts, retries, idempotency, distributed data, now lives inside your own architecture.

flowchart LR
  SP["you split into services"] --> C1["network in the middle
slow, and it can fail"] SP --> C2["partial failure
one down, the rest must cope"] SP --> C3["distributed data
no easy transactions or joins"] SP --> C4["ops overhead
deploy, observe, secure each one"] SP --> C5["harder testing
end to end across services"] classDef s fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef bad fill:#FEE4E2,stroke:#1F2937,color:#0E1726; class SP s; class C1,C2,C3,C4,C5 bad;
💡 In plain English

This is why we did S5 and S6 first. Microservices do not remove distributed-systems problems, they sign you up for all of them. If that list scares you, that is the correct reaction; it is the price of admission.

When to split · 55–72 min

Conway's law: your system will copy your org chart.

Systems end up shaped like the communication structure of the teams that build them. So service boundaries and team boundaries should match on purpose, otherwise the architecture and the org quietly fight each other.

flowchart TB
  subgraph ORG["how your teams are organised"]
    T1["team A"]
    T2["team B"]
    T3["team C"]
  end
  T1 --> M1["component A"]
  T2 --> M2["component B"]
  T3 --> M3["component C"]
  classDef team fill:#FEF3C7,stroke:#1F2937,color:#0E1726;
  classDef comp fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
  class T1,T2,T3 team;
  class M1,M2,M3 comp;
        
💡 In plain English

Three teams will build three things, whether you planned three or not. The "inverse Conway move" is to design the teams you want first, and let the architecture follow. Boundaries that cut across teams never hold.

The senior question

A split needs a real axis, not a vibe.

Before you extract a service, you must be able to name which of these genuinely differs across the boundary. If you cannot name one, keep it a module. Each of these is a legitimate reason; "it felt cleaner" is not.

flowchart TB
  Q{"why split this off?"}
  Q --> A1["scales
independently"] Q --> A2["changes at a
different rate"] Q --> A3["must fail
in isolation"] Q --> A4["owned by a
different team"] Q --> A5["different tech or
compliance need"] A1 --> OK["a real seam ·
the split is earned"] A2 --> OK A3 --> OK A4 --> OK A5 --> OK classDef q fill:#DCEBFE,stroke:#1F2937,color:#0E1726; classDef a fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class Q q; class A1,A2,A3,A4,A5 a; class OK ok;
💡 In plain English

The interview-and-promotion habit: when someone proposes a service, ask "which axis?" If the honest answer is none of these five, the right design is a module in the monolith, not a new service.

The decision, as a tree

Should this be its own service?

flowchart TB
  S{"should this be
its own service?"} S -->|"no axis differs"| KEEP["keep it a module
in the monolith"] S -->|"one real axis"| MAYBE{"can it cleanly
own its data?"} MAYBE -->|"no, it shares tables"| KEEP MAYBE -->|"yes, clean boundary"| SPLIT["extract a service"] classDef q fill:#DCEBFE,stroke:#1F2937,color:#0E1726; classDef keep fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef split fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class S,MAYBE q; class KEEP keep; class SPLIT split;
💡 In plain English

The data question is the killer. If two would-be services can't stop sharing the same tables, they are not really separate, and splitting them just gives you a distributed monolith. Own the data or stay a module.

Right-sizing

Too big loses the point. Too small is its own disease.

Once you commit to services, size matters. Make them too coarse and you keep the monolith's coupling. Make them too fine and a single user action becomes a dozen network hops. Aim for a service a team can own and reason about whole.

Too coarse
a "service" that is really three concerns glued together: you split nothing
under-split
Right-sized
one clear responsibility, owns its data, a team holds it in their head
aim here
Nano-services
so fine that one request fans out to ten; latency and ops explode
over-split
💡 In plain English

A good service is "a thing one team owns and can explain on a whiteboard." If a single click triggers a relay race across ten services, you have sliced too thin and bought yourself the worst of both worlds.

Event-driven & serverless · 72–85 min

Event-driven: stop calling, start announcing.

Instead of one service calling the next and waiting, a service emits an event to a broker and moves on. Anyone who cares subscribes. The producer does not know or wait for the consumers. This is decoupling in time, not just in space.

flowchart LR
  P["producer
order placed"] --> B["event broker
Kafka · SQS · Rabbit"] B --> C1["billing"] B --> C2["shipping"] B --> C3["email"] classDef p fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef b fill:#DCEBFE,stroke:#1F2937,color:#0E1726; classDef c fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class P p; class B b; class C1,C2,C3 c;
💡 In plain English

Instead of phoning each department one by one and waiting on hold, you pin a note to the board: "order placed." Billing, shipping and email each read it when they are ready. Add a new department later and the producer never changes.

Three flavours of "event"

Not all events carry the same thing.

"Event-driven" hides three different patterns, and mixing them up causes real pain. Know which one you mean.

1
Event notification
"something happened, go look." Tiny message, consumer calls back for detail. Loose, but chatty.
2
Event-carried state transfer
the event carries the data the consumer needs, so it never calls back. Fewer calls, some duplication.
3
Event sourcing
the log of events is the source of truth; rebuild state by replaying it. Powerful, and a big commitment.
💡 In plain English

Notification is a doorbell: "someone's here," go check. State transfer is a parcel: everything you need is in the box. Event sourcing is keeping every receipt forever and recomputing your balance from them. Most teams want the first two; reach for the third deliberately.

Coordinating a multi-step flow

Orchestration vs choreography.

When a workflow spans several services, who drives it? A central conductor (orchestration), or each service reacting to the last one's events (choreography)? Same trade you met in S6 with sagas.

flowchart TB
  subgraph O["orchestration · one conductor"]
    OR["orchestrator"] --> O1["step 1"]
    OR --> O2["step 2"]
    OR --> O3["step 3"]
  end
  subgraph C["choreography · react to events"]
    E1["service 1"] -->|"event"| E2["service 2"]
    E2 -->|"event"| E3["service 3"]
  end
  classDef orc fill:#DCEBFE,stroke:#1F2937,color:#0E1726;
  classDef cho fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
  class OR,O1,O2,O3 orc;
  class E1,E2,E3 cho;
        
💡 In plain English

Orchestration is an orchestra with a conductor: one place owns the order, easy to follow and debug. Choreography is dancers reacting to each other: beautifully decoupled, but nobody can show you the whole dance. Start with orchestration.

One more point on the dial

Serverless: functions that appear, run, and vanish.

Take it further: no long-running service at all. A function spins up in response to an event, does one job, and disappears. You pay per call and never manage a server. Great for spiky, event-shaped work; awkward for steady, stateful, latency-sensitive paths.

flowchart LR
  EV["an event
http · queue · cron"] --> FN["function
spins up on demand"] FN --> OUT["does one job,
then disappears"] FN --> DB[("managed store")] classDef ev fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef fn fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef db fill:#DCEBFE,stroke:#1F2937,color:#0E1726; class EV ev; class FN,OUT fn; class DB db;
💡 In plain English

Like a motion-sensor light: nobody there, nothing runs, no cost. Someone walks in, it switches on for exactly as long as needed. Brilliant for bursty jobs, a poor fit when you need the light on steadily or instantly (cold starts).

A distinction people miss

Internal shape is not the same as deployment shape.

"Layered" and "hexagonal" describe how you organise code inside a unit. "Monolith" and "microservices" describe how you deploy it. They are different axes: a monolith can be beautifully hexagonal inside, and a clean internal design is what makes a future split possible.

flowchart LR
  IN["inbound adapters
http · cli · queue"] --> CORE["domain core
pure logic, no I/O"] CORE --> OUT["outbound adapters
db · email · payments"] classDef io fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef core fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class IN,OUT io; class CORE core;
💡 In plain English

Hexagonal (ports and adapters) keeps the business logic in the middle, pure, with the outside world plugged in at the edges. Do this inside your monolith and the day you extract a service, the seam is already drawn.

The trap · 85–90 min

The distributed monolith: all the cost, none of the gain.

The worst of both worlds. You paid for the network, the ops, and the distributed data, but the services are still so coupled they must be deployed together and share a database. You took on every cost of splitting and kept every cost of coupling.

flowchart LR
  A["service A"] -->|"sync call"| B["service B"]
  B -->|"sync call"| C["service C"]
  C -->|"sync call"| A
  A -.->|"shared tables"| DB[("one database")]
  B -.-> DB
  C -.-> DB
  classDef bad fill:#FEE4E2,stroke:#1F2937,color:#0E1726;
  classDef db fill:#DCEBFE,stroke:#1F2937,color:#0E1726;
  class A,B,C bad;
  class DB db;
        
💡 In plain English

You moved every room into its own cabin, then ran a hallway between them all and kept one shared fridge. Now you have the walk in the rain and you still fight over the milk. This is the most common way microservices go wrong.

Self-diagnosis

Five signs you built a distributed monolith.

Lock-step deploys
you can't release one service without releasing others at the same time
sign
A shared database
multiple services read and write the same tables
sign
Chatty sync chains
one request hops A→B→C→D synchronously, each waiting on the next
sign
A change touches many
one feature needs edits across several services at once
sign
💡 In plain English

If any of these is true, you have the costs of distribution without the independence. The fix is usually not more services, it is fixing the boundary, often by pulling pieces back together into a module.

If you do need to split

Migrate with a strangler fig, not a big bang.

You almost never rewrite a monolith into services at once; that is how rewrites die. Instead, put a router in front, then peel off one capability at a time into its own service, until the old monolith is gone or small.

flowchart LR
  CL["clients"] --> F["router / façade"]
  F -->|"most routes"| MONO["the monolith"]
  F -->|"one peeled-off route"| SVC["new service"]
  MONO --> DB[("shared DB, for now")]
  SVC --> NDB[("its own DB")]
  classDef cl fill:#DCEBFE,stroke:#1F2937,color:#0E1726;
  classDef mono fill:#FEF3C7,stroke:#1F2937,color:#0E1726;
  classDef svc fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
  class CL,F cl;
  class MONO,DB mono;
  class SVC,NDB svc;
        
💡 In plain English

Named after a fig that grows around a tree and slowly replaces it. The router lets you move one route at a time, with the old system still running, so you can stop or reverse at any point. Safe, boring, and it actually finishes.

The whole spectrum on one card

Styles side by side.

StyleDeployScalingDataFailureBest when
Monolithone unitall togetherone DB, easy txnsshared blast radiusearly, small team, find the domain
Modular monolithone unitall togetherper-module tablesshared, but containedthe default for most teams
Microservicesindependentper servicea DB per serviceisolated, if done rightreal scaling / team / failure axis
Event-drivenindependentper consumerevents + local statedecoupled in timefan-out, async work, loose coupling
💡 In plain English

Read top to bottom as "more independence, more complexity." Most products live happily on the top two rows for years. The bottom two are tools you reach for when a named pressure demands them.

Traps that look like progress

Five ways teams get this wrong.

Microservices on day one
distributed complexity before you even know the domain boundaries
too early
Splitting by the org chart
boundaries that follow politics, not a real axis
wrong seam
A shared database
"services" that all read the same tables: a distributed monolith
fake split
Nano-services
so fine-grained that latency and ops swamp the benefit
too far
💡 In plain English

Every trap is the same root error: splitting without a real axis. Name the axis first. If you can't, the answer is a module, not a service.

Your turn · live

Place your system, and defend one boundary.

Take a system you know. Answer four things out loud:

1Where does it sit on the spectrum today, and is that the right spot?
2Pick one service or module: which real axis justifies its boundary?
3Does it cleanly own its data, or does it share tables with a neighbour?
4Spot one boundary that smells like a distributed monolith. What would you do?
💡 In plain English

No diagrams needed, just the four answers. If question two has no honest answer for some boundary, you have just found a service that should have stayed a module.

Recap · then what's next

Start coupled. Split on a seam. Never share a database.

The spectrum runs monolith → modular monolith → microservices → event-driven. Default to the modular monolith, split only where a real axis (scaling, change rate, failure, team, compliance) diverges, give each service its own data, and watch for the distributed monolith.

Next · S8 Domain-Driven Design
Tonight is the styles. Friday is how to find the seam, bounded contexts are where service boundaries actually come from.
the how
Toward ADR #2
Project 3, due Jul 3: defend one service boundary in writing, with the real axis named. Bring a candidate Friday.
bring it