Monolith vs Microservices
What splitting a system into services really buys you, what it costs (the network, partial failure, data ownership, deploys, teams), how to build the modular monolith most teams actually need, and how to split safely when you have a real reason.
The 30-second version
A five-person startup reads that big tech companies run hundreds of services. They split their shop into twelve. Six months later they spend their week fixing deploy pipelines and chasing timeouts between services, and the features have slowed to a crawl.
First, the two words. A Monolith is an application built and run as one program: one build, one deploy, usually one database. Microservices split the same application into many small programs that each own one job and talk to each other over the network.
Neither is "old" or "modern". They're two ends of a range (§03), and the right point on it depends on your team size, your traffic and how much operational work you can carry.
Here's a single checkout in our example shop, both ways:
The network isn't free
Data is the hard part
Boundaries first
Primer: the words you need, and our example shop
This page uses a handful of words over and over. Here they are in plain language. If you know them, skip to the example shop below.
Six words
Process
node server.js you started is one Process. Two processes can't call each other's functions; they can only send each other messages.Deploy
Module
go.mod project — we'll say Go module when we mean that.)API
Coupling
A service is a program that runs as its own process (often several copies of it, behind a Load balancer), owns one job, and is deployed on its own schedule. A microservice is just a small one.
Shopfront: our example all page long
Shopfront is an online shop. It has five parts, each a business capability (something the business does, named in business words):
| Part | Its job | Its data |
|---|---|---|
| catalog | products, prices, photos | products |
| orders | carts and placed orders | orders, order_items |
| inventory | how many of each item are in stock | stock |
| payments | charging cards, refunds | charges |
| shipping | delivery quotes, parcels | shipments |
When a customer clicks Buy, checkout does four things: reserve the items, charge the card, book the shipping, and save the order. In a monolith, each step is a plain function call:
func (s *Shop) Checkout(ctx context.Context, cart Cart) (Order, error) {
if err := s.inventory.Reserve(ctx, cart.Items); err != nil { // ← function call
return Order{}, err
}
receipt, err := s.payments.Charge(ctx, cart.ID, cart.TotalCents) // ← function call
if err != nil {
s.inventory.Release(ctx, cart.Items) // undo step 1
return Order{}, err
}
quote, err := s.shipping.Book(ctx, cart.Address) // ← function call
if err != nil {
s.payments.Refund(ctx, receipt) // undo step 2
s.inventory.Release(ctx, cart.Items) // undo step 1
return Order{}, err
}
// (if Save fails, we've charged but not saved — §05's sagas handle that)
return s.orders.Save(ctx, cart, receipt, quote)
}Keep this code in mind. In §04 each of those function calls becomes a network call, and we'll count what changes.
It's a spectrum, not a binary
Think of city zoning. A village where every building can be anything, anywhere, is easy at first and chaos later. A zoned town (homes here, shops there, one road network) stays livable as it grows. Splitting into separate cities with their own councils makes sense only when the town is huge and the parts really are different.
Still one program, but split by business capability into modules. Each module owns its tables and exposes a small public API; the build fails if someone reaches inside.
Fits: The sensible default for most teams, small or large (Shopify famously runs one with hundreds of engineers). Splitting a module out later is a far smaller job.
What moving right actually buys
Each of these is real, and each is something you can get without going all the way to the right:
- Independent deploys. The payments team can ship at 3 pm without waiting for the catalog team's release. You need it when teams block each other's releases.
- Independent scaling. If image resizing needs 20 machines and everything else needs 2, you run 20 copies of only the resizer. In a monolith you'd run 20 copies of everything. Often you don't need it: copies of a monolith are cheap to run when most of them are idle.
- Team autonomy. A team owns its code end to end: language version, libraries, database migrations, release schedule.
- Fault isolation. A memory leak in the PDF generator kills the PDF process, not checkout. Inside one process, one bad module can crash everything. This keeps the blast radius of a failure small.
- Tech freedom. One part can be in Go, another in Python for its machine-learning libraries.
What it costs
- Network calls instead of function calls: slower, and they fail in new ways (§04).
- Data split across databases: no more one-transaction-for-everything, no more SQL joins across parts (§05).
- More things to run: each service needs its own build pipeline, deploys, dashboards, alerts, and someone who answers when it breaks.
- Harder debugging: one slow request may cross six services, so you need tools to follow it.
What the network really costs
Going back to the food court: walking to another stall takes time; sometimes the stall is closed; and sometimes you hand over your order and money, the stall gets busy, and you never hear back. Did they make your burger? You can't tell from where you stand.
Measured: one call, both ways
We timed the same tiny function, priceOf(sku), called directly and called through a local HTTP server on the same machine. "Local" means over loopback (127.0.0.1): the request never leaves the computer, so this is the best case for a network call.
//go:noinline
func priceOf(sku string) Price { return Price{SKU: sku, Cents: 1999} }
func BenchmarkInProcess(b *testing.B) {
for b.Loop() {
_ = priceOf("mug-blue") // ← a plain call
}
}
func BenchmarkLocalHTTP(b *testing.B) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(priceOf(r.URL.Query().Get("sku")))
}))
defer srv.Close()
for b.Loop() {
resp, err := srv.Client().Get(srv.URL + "/price?sku=mug-blue") // ← over HTTP
if err != nil {
b.Fatal(err)
}
var p Price
json.NewDecoder(resp.Body).Decode(&p)
resp.Body.Close()
}
}$ go test -bench . -benchtime 2s
BenchmarkInProcess-4 1000000000 2.000 ns/op
BenchmarkLocalHTTP-4 45712 46451 ns/op (other runs: 51373, 56992)
$ node bench.mjs
in-process: 2.0 ns/call (other runs: 1.5–2.8 ns)
local HTTP: 119 µs/call (other runs: 123 µs on a quiet machine,
280–460 µs while it was busy)So: about 2 ns vs about 50 µs in Go (ns = a billionth of a second, µs = a millionth, ms = a thousandth). That's roughly 25,000 times slower, and it's with no real network, no encryption (TLS) and a tiny JSON body. Node's fetch adds more overhead: roughly 100,000 times slower here, and the exact ratio moved a lot with how busy the machine was. The lesson is the same. Between two machines in the same datacenter (one building full of servers), add a round trip of very roughly 0.1–1 ms; between cloud regions (datacenters in different cities or continents), tens of milliseconds.
Failures multiply
In one process, checkout's four steps all run or the whole process is down. Over the network, each call can fail on its own while the others work. That's called partial failure, and it's the normal state of a system of services.
If each call succeeds 99.9% of the time, independently, then a request that needs N calls in a row succeeds with probability 0.999N:
| Calls in a row | Success | Failed checkouts per million | Same as this much full downtime per month (approx.) |
|---|---|---|---|
| 1 | 99.9% | 1,000 | 43 min |
| 5 | 99.5% | 4,990 | 3.6 h |
| 10 | 99.0% | 9,955 | 7.2 h |
| 20 | 98.0% | 19,811 | 14.3 h |
Try it, and turn on retries:
Each extra call is one more chance to fail. With 5 calls, 0.5% of checkouts fail even though every service is “99.9% available”.
Three things to notice in the widget:
- Every service can be "99.9% available" and a checkout that needs 10–20 calls still fails 1–2% of the time. (Shopfront's 4 calls: about 0.4%.) Users experience the product, not each service.
- Slow calls add up too. If 1 call in 100 is slower than usual (its p99), a request that makes 10 calls hits at least one slow one about 10% of the time (1 − 0.9910). Your slowest requests become common. That's called tail latency.
- Retries help, with a catch. Repeating a failed call is only safe if doing it twice has the same effect as doing it once — if it's Idempotent. Charging a card twice is not.
The same call, now over the network
Here's checkout's payments.Charge call after payments moves to its own service. The orders code still calls a Charge method — but the method now has to do all this:
func New(baseURL string) *Client {
return &Client{BaseURL: baseURL, HTTP: &http.Client{Timeout: 2 * time.Second}} // ← new: a timeout
}
func (c *Client) Charge(ctx context.Context, orderID string, cents int64) (string, error) {
body, err := json.Marshal(map[string]any{"order_id": orderID, "cents": cents}) // ← new: serialize
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"/charges", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "charge-"+orderID) // ← new: safe to retry
resp, err := c.HTTP.Do(req)
if err != nil {
return "", fmt.Errorf("payments unreachable: %w", err) // ← new: did it charge? unknown!
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("payments: HTTP %d", resp.StatusCode) // ← new: status codes
}
var out struct {
ReceiptID string `json:"receipt_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { // ← new: deserialize
return "", err
}
return out.ReceiptID, nil
}When payments hangs or is down, here's what each version really reports (run on this machine):
payments unreachable: Post "http://127.0.0.1:37001/charges": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
payments unreachable: Post "http://127.0.0.1:1/charges": dial tcp 127.0.0.1:1: connect: connection refusedTimeoutError The operation was aborted due to timeout
TypeError fetch failedThe first error in each pair is the dangerous one. The request was sent; payments may have charged the card and the reply got lost. Only an Idempotency key lets you retry without charging twice — see Idempotency → keys, end to end. The second error is safe: the connection never opened, so nothing was sent. And a 5xx status (500, 502, 503) is also "don't know": the request reached payments, which may have charged the card and then crashed before answering. Retry it with the same key.
Count the new lines. Every one of them is a decision a function call never needed: a Timeout value, Serialization, status codes, retries, what "unknown" means. People have made these mistakes so often there's a famous list of them, the fallacies of distributed computing: "the network is reliable", "latency is zero", and six more.
Who owns the data?
In the food court, each stall has its own fridge. If the burger stall starts taking cheese out of the pizza stall's fridge, the pizza stall can't reorganise its fridge, can't tell how much cheese it has, and gets blamed when the cheese runs out. They're no longer separate stalls; they just look like it.
Why shared tables couple services
When two services read the same table, every column becomes a contract between their teams — one that nobody wrote down. The owner can't rename, split or re-mean a column without checking every other service's queries. Try it:
The third change is the scary one. Nothing errors. The status = 'ok' query just stops matching, and a report quietly shows zero revenue. With a shared database, teams end up deploying together and scheduling "migration nights": the services look separate, but they behave like one program.
With database per service, the table is private. The owner can change it freely, as long as its API and events keep their shape. Changing those is still a contract change, but it's an explicit, versioned one (more in §07).
"Database per service" doesn't mean "a database server per service"
You don't need ten Postgres servers. What you need is that no code can reach another part's tables. One Postgres server with a schema per module, and a database role per module that can only touch its own schema, enforces exactly that. This works for a modular monolith too. We ran it on Postgres 16:
CREATE SCHEMA orders;
CREATE SCHEMA payments;
CREATE TABLE orders.orders (id text PRIMARY KEY, status text, receipt_id text, total_cents bigint);
CREATE TABLE orders.outbox (id bigserial PRIMARY KEY, topic text NOT NULL, payload jsonb NOT NULL);
CREATE TABLE payments.charges (id text PRIMARY KEY, order_id text, amount_cents bigint);
CREATE ROLE orders_module LOGIN;
GRANT USAGE ON SCHEMA orders TO orders_module; -- may enter the schema
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA orders TO orders_module; -- tables that exist now
GRANT USAGE ON ALL SEQUENCES IN SCHEMA orders TO orders_module; -- bigserial ids
ALTER DEFAULT PRIVILEGES IN SCHEMA orders -- tables that future
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO orders_module; -- migrations add
ALTER DEFAULT PRIVILEGES IN SCHEMA orders GRANT USAGE ON SEQUENCES TO orders_module;
-- (the same for payments_module on schema payments)
-- later, a migration adds a table:
CREATE TABLE orders.refunds (id bigserial PRIMARY KEY, order_id text);postgres=> SELECT count(*) FROM orders.orders;
count
-------
0
(1 row)
postgres=> INSERT INTO orders.outbox (topic, payload) VALUES ('OrderPlaced', '{"order_id": "o-42"}');
INSERT 0 1
postgres=> SELECT * FROM orders.refunds;
id | order_id
----+----------
(0 rows)
postgres=> SELECT sum(amount_cents) FROM payments.charges;
ERROR: permission denied for schema payments
LINE 1: SELECT sum(amount_cents) FROM payments.charges;
^Three details make this work. GRANT … ON ALL TABLES only covers tables that exist right now, so ALTER DEFAULT PRIVILEGES extends the same rights to tables that later migrations create (like refunds above). A bigserial column fills itself from a counter called a sequence, and the role needs USAGE on it to insert rows. And migrations run as a separate owner role: the module role can read and write rows, but can't create or drop tables.
Each module's code connects with its own role. A query that reaches across the boundary now fails in the first test run, not in production six months later.
Queries that need several owners' data
"Show my last 20 orders, each with its payment status and delivery date." In the monolith, that's one JOIN. With services, the three pieces of data live in three databases, so there's no JOIN. You have two options.
Option 1: API composition. Ask each owner, then merge in code:
async function orderHistory(userId) {
const orders = await ordersApi.list(userId, { limit: 20 }); // 1 call
const ids = orders.map((o) => o.id);
const [charges, shipments] = await Promise.all([ // 2 more, in parallel
paymentsApi.chargesFor(ids), // ← batch endpoints:
shippingApi.shipmentsFor(ids), // one call for all 20
]);
const paid = new Map(charges.map((c) => [c.orderId, c.status]));
const eta = new Map(shipments.map((s) => [s.orderId, s.eta]));
return orders.map((o) => ({ ...o, payment: paid.get(o.id) ?? "unknown", eta: eta.get(o.id) ?? null }));
}This is simple and always fresh, but it's three network calls, and if shipping is down the whole page fails — unless you deliberately show "delivery date unavailable" (use Promise.allSettled for that). Ask owners for batch endpoints (chargesFor(ids)), never one call per order (§09).
Option 2: keep a local copy. Orders, payments and shipping publish events ("OrderPlaced", "PaymentCaptured", "ShipmentBooked") to a Message broker. An order-history component listens and keeps its own table with exactly the columns the page needs — a read model. Reads are one fast local query, but the copy is a little behind: right after paying, the page may still say "unpaid" for a moment. That's Eventual consistency, and the CQRS page covers building read models properly.
Writes that need several owners
Checkout must reserve stock (inventory's data), charge (payments' data) and save the order (orders' data). In the monolith, one database transaction could cover all three. Across services there's no transaction that spans them, so you need three tools — each has its own page:
- A saga: do each step as its own local transaction, and if a later step fails, run a compensating step for each earlier one (release the stock, refund the card). → Sagas
- The outbox: when a service saves data and must publish an event about it, it writes both in one local transaction — the event goes into an Outbox pattern table — and a background job publishes it. Without it, a crash between "save" and "publish" loses the event. → Queues → exactly-once: the outbox
- Idempotent consumers: events can arrive twice, so handlers must notice repeats. → Idempotency → consumers
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() // no-op if Commit succeeded
if _, err := tx.ExecContext(ctx,
`INSERT INTO orders.orders (id, status, total_cents) VALUES ($1, 'placed', $2)`, o.ID, o.TotalCents); err != nil {
return err
}
event, err := json.Marshal(OrderPlaced{OrderID: o.ID, TotalCents: o.TotalCents, Address: o.Address})
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO orders.outbox (topic, payload) VALUES ('OrderPlaced', $1)`, event); err != nil {
return err
}
return tx.Commit() // ← both rows, or neither. A relay publishes outbox rows later.Building a modular monolith in Go and JavaScript
Think of an apartment building. One building, one address, one front door, one set of pipes (that's the single deploy). But each flat has its own locked door: neighbours ring the bell (the public API) instead of walking in and rearranging your kitchen (your tables). The locks matter. Boundaries that rely on everyone being careful erode within months; boundaries the build enforces survive.
The Go layout
go.mod module example.com/shop
cmd/shop/main.go wiring: creates every module and connects them
internal/
orders/orders.go orders module: its exported names are its public API
payments/payments.go payments module: New, Service.Charge, ErrDeclined
payments/internal/stripe/ private to payments — the compiler enforces it
shipping/…Two Go rules do most of the work. Capitalised names are exported (visible to other packages); lowercase ones are private. And an internal/ folder can only be imported by code inside the folder that contains it. So payments/internal/stripe is usable by payments only.
package orders
// Payer is what orders needs from payments, and nothing more.
type Payer interface {
Charge(ctx context.Context, orderID string, cents int64) (receiptID string, err error)
}
type Service struct {
db *sql.DB // touches only the orders.* tables
payer Payer
}
func New(db *sql.DB, payer Payer) *Service { return &Service{db: db, payer: payer} }
func (s *Service) Place(ctx context.Context, orderID string, cents int64) error {
rid, err := s.payer.Charge(ctx, orderID, cents) // ← a plain function call, for now
if err != nil {
return fmt.Errorf("place order %s: %w", orderID, err)
}
_, err = s.db.ExecContext(ctx,
`UPDATE orders.orders SET status = 'paid', receipt_id = $1 WHERE id = $2`, rid, orderID)
return err
}Notice what orders does not do: it doesn't import payments at all. It declares the one method it needs, and main.go — the only package that imports every module — plugs payments in. That's the payoff we saw coming in §04: when payments moves to its own service, main.go passes the HTTP client instead — it also has a Charge method — and not one line of orders changes. (This is Dependency injection; its page goes deeper: Dependency injection.)
The JavaScript layout
In Node, each module becomes a package in one repository using npm workspaces, and each package's exports map says which files others may import:
{
"name": "shop",
"private": true,
"type": "module",
"workspaces": ["packages/*", "apps/*"]
}npm install links each package into node_modules/@shop/…, so main.js imports them by name. The exports map lists only "." (the package's main entry), so any deeper import is refused. Real output from Node 22:
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './src/internal/psp.js' is not defined by "exports" in …/node_modules/@shop/payments/package.json imported from …/apps/shop/sneaky.jsRules the language can't express: an architecture test
internal/ stops reaching inside a module, but it doesn't stop one module importing another's public API. In our design, no module imports another at all: orders uses its own Payer interface, shipping hears about orders only through events, and only cmd/shop wires modules together. For that, write a test that reads the real import graph and fails the build:
// Which module may import which other modules. Anything not listed is forbidden.
// Only cmd/shop (outside internal/) imports several modules, to wire them together.
var allowed = map[string][]string{
"orders": {}, // declares its own Payer interface
"payments": {},
"shipping": {}, // learns about orders from events only
}
func TestModuleBoundaries(t *testing.T) {
const prefix = "example.com/shop/internal/"
out, err := exec.Command("go", "list", "-f", `{{.ImportPath}} {{join .Imports " "}}`, "./internal/...").Output()
if err != nil {
t.Fatal(err)
}
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
fields := strings.Fields(line) // [package, import1, import2, …]
from := strings.SplitN(strings.TrimPrefix(fields[0], prefix), "/", 2)[0]
for _, imp := range fields[1:] {
if !strings.HasPrefix(imp, prefix) {
continue // standard library or third-party
}
to := strings.SplitN(strings.TrimPrefix(imp, prefix), "/", 2)[0]
if to == from || slices.Contains(allowed[from], to) {
continue
}
t.Errorf("%s imports %s: module %q may not depend on %q", fields[0], imp, from, to)
}
}
}--- FAIL: TestModuleBoundaries (0.04s)
arch_test.go:35: example.com/shop/internal/shipping imports example.com/shop/internal/orders: module "shipping" may not depend on "orders"
FAILTry the rules
Every way to cross a boundary, and which tool catches it. In Go, try the two amber arrows together: on their own each one builds, but both at once form a loop.
Teams, deploys and contracts: Conway's law
Organizations which design systems … are constrained to produce designs which are copies of the communication structures of these organizations.— Melvin Conway, “How Do Committees Invent?”, 1968
That's Conway's law, and it's less a theory than an observation. Code boundaries end up where people stop talking every day. If the web team and the backend team only meet at a weekly sync, the boundary between "web app" and "backend" will be a hard, formal API, whether anyone planned it or not.
Same product, two org charts
With teams by layer, every feature needs three teams, three backlogs and a coordinated release. With teams by capability, most features stay inside one team. The architecture follows: capability teams naturally build capability modules (or services) with narrow APIs between them.
Deliberately shaping teams to get the architecture you want is called the inverse Conway manoeuvre. The book Team Topologies (Skelton & Pais, 2019) builds on it: most teams should be "stream-aligned" (own one capability end to end), supported by a Platform team that makes building and running services easy.
What "independently deployable" really requires
Here's the test: can the payments team deploy on a Tuesday afternoon without telling anyone? If the answer is "only if orders deploys the same change first", they aren't independent, however many repositories you have.
The thing that breaks independence is the contract between services: the shape of requests, responses and events. Say payments wants to rename the cents field of POST /charges to amount_cents. If it just renames it, every caller breaks the minute payments deploys. Instead, do it in three deploys, each safe on its own:
- Expand. Payments accepts both names. Deploy payments. Nothing breaks: old callers still send
cents. - Migrate. Each caller switches to
amount_cents, on its own schedule. Deploy each caller. - Contract. Once logs show nobody sends
centsany more, payments drops it. Deploy payments.
type ChargeRequest struct {
OrderID string `json:"order_id"`
AmountCents int64 `json:"amount_cents"` // new name
Cents int64 `json:"cents"` // old name: accepted until every caller has moved
}
func (r ChargeRequest) amount() int64 {
if r.AmountCents != 0 {
return r.AmountCents
}
return r.Cents // ← old callers still work
}This expand → migrate → contract pattern is the same one used for database migrations (SQL vs NoSQL → changing the shape over time) and protobuf fields (gRPC → changing the contract safely). Versioned URLs like /v2/charges are the heavier tool for changes that can't be made compatible; the API design page covers versioning and evolution in depth.
Inside a modular monolith you get to skip all this. Rename the field, fix every caller in the same commit, and the compiler tells you if you missed one. That's a big, under-rated advantage of one deployable.
Splitting safely: the strangler fig
The tempting plan is the big-bang rewrite: freeze the old system, build the new services, switch over one weekend. It's famous for going badly. The old system keeps changing while you rewrite, the new one has to match years of undocumented behaviour, and nothing is proven until the scary switch-over night.
The alternative is named after the strangler fig, a vine that grows around a host tree branch by branch until it stands on its own. The Strangler fig pattern (named by Martin Fowler) does the same to software: something new grows around the old system, one route at a time, and old code is deleted only once nothing reaches it.
1// 1. the proxy: requests from OUTSIDE (browsers, card-provider callbacks)2mux := http.NewServeMux()3mux.Handle("/payments/", split(share, orderOf, paymentsSvc, monolith)) // step 4+4mux.Handle("/", monolith)56// 2. inside the monolith: calls from the orders module7var local orders.Payer = payments.New(db)8remote := paymentsclient.New(paymentsURL) // step 3+9payer := orders.FlaggedPayer{Share: share, Remote: remote, Local: local} // step 4+10ord := orders.New(db, payer)
Step 1 in code: the routing proxy
The proxy is a Reverse proxy: clients talk to it, and it forwards each request to the right backend. In production this is often nginx, Envoy or your cloud's load balancer, but it's small enough to write yourself:
func proxyTo(raw string) *httputil.ReverseProxy {
target, err := url.Parse(raw)
if err != nil {
log.Fatal(err)
}
return &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(target) // send to target, keep the path
r.SetXForwarded() // tell the backend who the real client was
},
}
}
func main() {
monolith := proxyTo("http://localhost:19001")
payments := proxyTo("http://localhost:19002")
mux := http.NewServeMux()
mux.Handle("/payments/", payments) // ← the one route we've moved
mux.Handle("/", monolith) // ← everything else: unchanged
log.Fatal(http.ListenAndServe(":18080", mux))
}$ curl localhost:18080/orders/42
monolith handled GET /orders/42
$ curl -X POST localhost:18080/payments/charge
payments-svc handled POST /payments/charge
$ curl localhost:18080/cart
monolith handled GET /cartWhy "make it a module first"?
Step 2 of the stepper — turning payments into a real module inside the monolith — is where most of the work and most of the learning happen. You discover every place that reached into payments' tables, every hidden assumption, every transaction that spanned both. Fixing those inside one program is cheap: one commit, one deploy, the compiler helps. Discovering them after the network is in the way is expensive.
If the module boundary turns out to be wrong, you move it in an afternoon. If a service boundary turns out to be wrong, you're coordinating two teams, two deploys and a data migration.
Moving the data
The new service needs its own copy of payments' data before it takes traffic:
- Backfill: copy the existing rows in batches.
- Change data capture: stream every new change from the old tables until the switch, so the copy stays current.
- Verify before trusting: send the new service shadow traffic (a copy of real requests, whose answers are only compared, never used) until the answers match.
While both systems take real traffic (steps 4–5), each one writes payments for its share of orders. Anything that reads across all orders — a refunds report, say — must read both, or the new service's writes must also be copied back. Keep this phase short.
Moving traffic: two roads, one flag
Here's the part that's easy to miss. Two kinds of traffic reach payments, and each needs its own switch:
- Requests from outside — a customer's refund page, the card provider calling back to say a payment cleared. They arrive at the proxy, which decides per URL.
- Calls from inside the monolith — orders calling
Payer.Charge()at checkout. These never pass through the proxy. They're switched by a Feature flag inside the monolith that swaps whichPayerorders gets.
Dashed = a network call to the new service. Solid = the old in-process path. Move both switches together to finish the migration.
Both switches read the same flag, a percentage you can change at run time: 1%, 10%, 50%, 100% — the same idea as a Canary release. And both pick by order ID, not at random, so every request and retry about one order goes to the same side. (A retry that lands on the other system wouldn't find the first attempt's Idempotency key, and could charge twice.)
// bucket turns an order ID into a stable number in [0, 1).
// The same order always gets the same number, so its retries go to the same side.
func bucket(orderID string) float64 {
h := fnv.New32a()
h.Write([]byte(orderID))
return float64(h.Sum32()) / (1 << 32)
}
// FlaggedPayer sends a share of orders to remote, the rest to local.
type FlaggedPayer struct {
Share func() float64 // reads the flag: 0.01 means 1%
Remote, Local Payer
}
func (p FlaggedPayer) Charge(ctx context.Context, orderID string, cents int64) (string, error) {
if bucket(orderID) < p.Share() {
return p.Remote.Charge(ctx, orderID, cents) // → payments-svc, over HTTP
}
return p.Local.Charge(ctx, orderID, cents) // → the old in-process module
}On this machine, with the share set to 0.5, bucket sent 515 of 1,000 sample order IDs to the new side — close to half, as it should be. Rolling back is setting the flag to 0.
Smells: the distributed monolith
It's the food court where the stalls share one fridge, can only open when all the others open, and send a runner to the next stall for every single fry. It looks like a food court. It works like one badly run restaurant with very long corridors. The anti-patterns page has a picture of how teams walk into this; here's how to diagnose and fix it.
The smells, and what each one means
| Smell | You notice… | Root cause | Fix |
|---|---|---|---|
| Lockstep deploys | "Deploy B first, then A, then C, all tonight." | Contracts change in breaking ways | Expand → migrate → contract (§07) |
| Shared tables | Two services' code queries the same table | No single data owner | One owner; others use its API or events (§05) |
| Chatty calls | One page = 40 calls to the same service | Calls per item, or boundary through the middle of one capability | Batch endpoints, a local copy, or merge the services |
| Long synchronous chains | A waits for B, which waits for C, which waits for D | Every step demands an immediate answer | Use events for anything that doesn't need an answer now |
| Entity services | OrderService is just create/read/update/delete on one table; the rules live in its callers | Split by noun (data), not by capability | Move the rules next to the data they guard |
| Shared domain library | A "common" package with business rules that every service must upgrade together | Logic copied into a library instead of owned by one service | Share only plumbing (logging, HTTP clients); keep rules in their owner |
| One feature = PRs in 5 repos | Every change crosses services | Boundaries cut across how the business changes | Redraw boundaries; sometimes merge services |
Chatty calls, up close
The order page shows 6 items and needs each one's stock level. Called one item at a time, that's 6 round trips; it's the N+1 query problem, now with a network in the middle:
The usual first fix is a batch endpoint: one request carries every ID.
mux.HandleFunc("GET /stock", func(w http.ResponseWriter, r *http.Request) {
raw := r.URL.Query().Get("skus") // ?skus=1,2,3
if raw == "" {
http.Error(w, "skus is required", http.StatusBadRequest)
return
}
skus := strings.Split(raw, ",")
if len(skus) > 100 {
http.Error(w, "at most 100 skus per call", http.StatusBadRequest) // ← cap the batch
return
}
levels, err := inv.Levels(r.Context(), skus) // one SQL query: WHERE sku = ANY($1)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(levels) // {"1": 4, "2": 0, …}
})If two services are chatty no matter what you batch, that's the boundary telling you something: they're one capability, cut in half. Merge them.
Long synchronous chains
A synchronous call means the caller waits for the answer before it continues. A chain A → B → C → D of synchronous calls means A only works when B, C and D all work (§04's 0.999N), and A is as slow as all of them added up. This is called temporal coupling: the services must all be up at the same time.
Ask, for each call: does the caller need the answer to continue?
- Checkout needs to know the card was charged before saying "thank you". That stays a call.
- Checkout doesn't need the confirmation email to be sent, the search index updated or the analytics recorded. Publish an "OrderPlaced" event instead (Publish/subscribe) and let those services react whenever they're ready. If the email service is down for an hour, checkout doesn't care; emails go out when it's back.
Staff-level: deciding, running, and merging back
The decision
Proposals to "move to microservices" usually mix up two questions. Do we have a problem that a network boundary solves? And can we operate what we'd create? Both answers must be yes.
Notice what's not a reason. "Our code is a mess" — a mess split into services is a distributed mess, and now it's harder to refactor. "We need to scale" — a Monolith scales out fine: run more identical copies behind a Load balancer (Load balancing). You need separate services for scale only when parts scale differently.
The operational bill, per service
Each new service brings a fixed set of chores, whether it handles 10 requests a day or 10,000 a second:
- A pipeline. Its own build, tests and automated deploy (CI and continuous delivery). Manual deploys across 15 services don't happen often enough to stay safe.
- Observability. When one request crosses five services, logs from one service tell you almost nothing. You need Distributed tracing to follow a request across services, central logs, and Metrics per service. Build this before the split; it helps the monolith too. → Observability
- Someone on call. A named owner who gets woken up when it breaks, with alerts and a short written guide for each one.
- Security between services. Every internal call now crosses a network, so services must prove who they are (often mTLS) and check what the caller may do.
- Running it. At least two copies for availability, health checks, containers, usually a platform such as Kubernetes, and dependency upgrades in every service.
- Local development. "Run the app on my laptop" now means running, or faking, its neighbours.
A Platform team exists to make this bill smaller: service templates that come with a pipeline, logging and dashboards already wired in. Without one, every team pays the full bill itself.
A rough cost model
Use this to sanity-check a proposal, not to decide by itself. All numbers are your own estimates.
| Cost | Grows with | What to estimate |
|---|---|---|
| Fixed ops per service | number of services | engineer-hours per month per service (upgrades, alerts, pipeline fixes) |
| Idle capacity | number of services | minimum copies × machine size, even at zero traffic |
| Network overhead | calls per request × traffic | CPU spent on JSON and TLS; in the cloud, traffic between availability zones is often billed per GB |
| Coordination | cross-service changes | hours spent on contract changes, migrations and joint releases |
| Benefit | blocked teams, different scaling needs | hours of waiting removed; machines saved by scaling only the hot part |
If the benefit row isn't clearly bigger, the split is a hobby. Martin Fowler's long-standing advice, "MonolithFirst", says the same: start with a monolith, find the boundaries by living with them, and pay the "microservice premium" only once it's worth it.
Merging services back
Merging isn't failure; it's the same boundary decision made with better information. Signs a pair of services should be one:
- they're always deployed together, or always changed in the same pull request (§09's change coupling);
- one is chatty with the other no matter how you batch;
- a single small team owns both and spends more time on plumbing than features;
- the split solved a problem you no longer have.
Public examples exist. Segment wrote in 2018 ("Goodbye Microservices") about folding over a hundred near-identical destination services back into one service, after the per-service overhead swamped a small team. In 2023, an Amazon Prime Video team described moving a video-quality monitoring tool from many distributed components into a single process and reported cutting its infrastructure cost by about 90%. Both are one team's account of one system, not universal laws, but they show the costs in §04 are real money.
The mechanics are the strangler fig in reverse: bring the code into one deployable as modules, keeping each old service's API as that module's public API; move the tables into one database, still one schema per module; route traffic to the merged program; delete the old services. You keep the boundaries and drop the network.
Pitfalls gallery
Splitting because big companies do
A team of five copies an architecture built for a company with five hundred engineers. They get the costs (pipelines, network failures, on-call for twelve services) without the problem those companies were solving: many teams blocking each other. That's cargo-cult architecture. Start from a named problem (§10's checklist), not from a diagram you admire.
Splitting by technical layer
"Frontend service, business-logic service, data-access service." Every feature now changes all three, and every request crosses all three (§07). Split by business capability — orders, payments, shipping — so that a typical change stays inside one boundary.
Keeping one shared database “for now”
The code is split; the tables aren't. Within weeks, services query each other's tables because it's quicker than adding an API, and you're back to lockstep deploys (§05). Give each table one owner from day one — a schema and a role per service is enough to start.
No timeouts on internal calls
// ✕ If inventory hangs, this waits up to 5 minutes (Node's built-in default), holding a connection the whole time
const stock = await fetch(`${INVENTORY}/stock?skus=${ids}`);Go's default http.Client has no timeout at all, and Node's fetch waits up to 300 seconds for headers by default. One stuck service then ties up every caller's connections and memory until they fall over too: a cascading failure. Give every call a Timeout (AbortSignal.timeout(ms), http.Client{Timeout: …} or a context deadline), and consider a Circuit breaker that stops calling a service that keeps failing.
Retrying everything, immediately
A call times out, so the caller retries — three times, at once. Multiply by every caller and a slow service now gets four times its normal load, exactly when it can least handle it (a Retry storm). Retry only operations that are safe to repeat (or carry an Idempotency key), with growing, randomised waits and a cap. → Idempotency → retrying well
One call per item
A loop that calls another service once per order line turns a 1 ms page into a 50 ms one, and multiplies failure chances (§04, §09). Ask the owner for a batch endpoint, or keep a local copy fed by events.
Splitting before you can see
The first incident after the split: a checkout is slow, and nobody can tell which of six services is the cause, because each has its own logs and no request ID ties them together. Add Distributed tracing, central logs and per-service dashboards before the first extraction (§10).
The big-bang rewrite
"We'll rebuild it all as services and switch over in Q3." Q3 becomes Q1 next year, the old system keeps changing underneath, and the cut-over night finds a hundred behaviours nobody wrote down. Use the strangler fig (§08): one capability at a time, behind a proxy and a flag, each step reversible.
A “modular” monolith with no enforcement
The modules exist only in a diagram. Six months later, shipping imports orders/db (a package nobody thought to put under internal/) "just this once", and the boundaries have melted into a Big ball of mud. Make the build enforce them: internal/ folders, exports maps, a lint rule, an architecture test and database roles (§06).
Check yourself
Commit to an answer before you click. Each explanation points back to the section that covers it.
A checkout calls 8 services one after another. Each one answers successfully 99.9% of the time, independently. Roughly what fraction of checkouts fail?
Team A deploys orders, team B deploys payments. Every release, both must deploy the same evening, because payments reads the orders table directly. What is the root cause?
// module example.com/shop
// file: internal/shipping/ship.go
package shipping
import "example.com/shop/internal/payments/internal/stripe"You want to move payments out of a monolith. Which order of steps is the safe one?
A company has a frontend team, a backend team and a database team. What architecture will they most likely end up with?
The orders service calls POST /charges on payments. The request times out after 2 seconds. What do you actually know?
Cheat sheet
| If you need… | Go | JavaScript |
|---|---|---|
| Hide a module's internals | payments/internal/… folder | "exports": { ".": "./src/index.js" } |
| Depend on another module | small interface in the caller (type Payer interface) | pass an object with the method ({ payer }) |
| Forbid module A → module B | architecture test over go list | ESLint no-restricted-imports, dependency-cruiser |
| Forbid reading another module's tables | Postgres schema + role per module | same |
| Timeout on a service call | http.Client{Timeout: 2 * time.Second} or a context deadline | fetch(url, { signal: AbortSignal.timeout(2000) }) |
| Route one path to a new service | httputil.ReverseProxy + mux.Handle("/payments/", …) | http.request(…) + pipe, or nginx / Envoy |
| Rename an API field safely | accept both names, then remove the old | body.amount_cents ?? body.cents |
| Should we split it out? | |
|---|---|
| Yes, if | several teams block each other's releases; one part scales or fails very differently; one part needs stricter isolation |
| And only if | the module boundary already exists and is stable; it gets its own pipeline, tracing and on-call; its data ownership is clear |
| Not because | "the code is a mess", "we need to scale" (copies of a monolith scale), "everyone does microservices" |