Skip to content
Software Atlas

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.

⏱ ~50 min read13 sections9 interactive widgetsGo · JavaScript · SQL
FoundationsPractitionerStaff-level
Foundations§01

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.

In one sentenceSplitting a program into services turns function calls into network calls. You buy independence (separate deploys, scaling and teams) and you pay for it with latency, new kinds of failure, and much harder data and debugging. Most teams should get clear boundaries first, inside one program, and split only for a concrete reason.

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:

One checkout, two architectures
Monolith: one restaurant, many stations
shop
checkout
reserve
charge
ship
save
Microservices: a food court, one stall per job
orders
checkout
→ inventory
→ payments
→ shipping
save
inventory
reserve
payments
charge
shipping
quote
Solid = code running. Striped = waiting for the network. In the monolith, each step is a function call inside one program. With services, each step is a request to another program that may be slow, or not answer at all. (Not to scale: the striped parts are really thousands of times longer than the solid ones — §04 measures it.)
1

The network isn't free

A function call takes nanoseconds and can't get lost. A network call takes tens of thousands of times longer even on the same machine, can time out, and sometimes you can't tell whether it worked (§04).
2

Data is the hard part

Splitting the code is easy. Splitting the database — who owns which table, and how to keep things consistent — is where projects get stuck (§05).
3

Boundaries first

A Modular monolith gives you most of the structure for a fraction of the cost. When a module really needs to be its own service, you extract it step by step (§06, §08).
Foundations§02

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

P

Process

One running copy of a program, with its own memory. A Go binary or a node server.js you started is one Process. Two processes can't call each other's functions; they can only send each other messages.
D

Deploy

Shipping a new version to production. A deployable is anything you can deploy on its own. A monolith has one; every service adds another.
N

Network call

Asking another process to do something by sending it a message — usually an HTTP request — and waiting for the reply. Covered in depth on HTTP and gRPC.
M

Module

A folder of code with a clear job and a small public face. In Go a module of your app is usually a package; in JS, a folder or an npm package. (Go also uses "module" for a whole go.mod project — we'll say Go module when we mean that.)
A

API

The functions (or HTTP endpoints) one piece of code offers to others. A module's public API is the part others may use; the rest is private.
C

Coupling

How much one piece has to know about another. High Coupling means a change here breaks things there. Its partner word, Cohesion, means "the things inside one module belong together".

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):

PartIts jobIts data
catalogproducts, prices, photosproducts
orderscarts and placed ordersorders, order_items
inventoryhow many of each item are in stockstock
paymentscharging cards, refundscharges
shippingdelivery quotes, parcelsshipments

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:

checkout.go (monolith)
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.

Foundations§03

It's a spectrum, not a binary

In one sentence"Monolith or microservices?" is the wrong question. There are five rough points on a line, and moving right buys independence at the price of moving parts.

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.

The architecture spectrum
Click along the line. Watch what you gain (green) and what you pay (red). Dots are a rough 0–3 scale, not measurements.
Modular monolithdeploys: 1 program
shop (one program)orderspaymentsinventoryshippingcatalogone db,a schemaper module

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 you gain
Independent deploys
Independent scaling
Team autonomy
Fault isolation
Tech freedom
What you pay
Network calls per request
Keeping data consistent
Things to run & watch
Risk that a change breaks something far away

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.
Practitioner§04

What the network really costs

In one sentenceA function call is fast and either returns or throws. A network call is thousands of times slower, and it has three outcomes: it worked, it failed, or you don't know. Chain several and their failure chances multiply.

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.

bench_test.go
//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()
	}
}
output on this machine (4-core Xeon, Go 1.24, Node 22, three runs each)
$ 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 rowSuccessFailed checkouts per millionSame as this much full downtime per month (approx.)
199.9%1,00043 min
599.5%4,9903.6 h
1099.0%9,9557.2 h
2098.0%19,81114.3 h

Try it, and turn on retries:

Turn function calls into network calls
One checkout makes N calls to other services, one after another. Each call succeeds with the chance you pick, and 1 in 100 calls is slow (slower than that service's own p99). 400 simulated checkouts below.
Each call succeeds:
On failure:
checkout succeeds: 99.50% = 99.90%5
failed per million: 4,990
≥1 slow call: 4.9% = 1 − 0.995
network time: ≈2.5 ms typical, more when slow
same steps in-process: ≈10 ns, and no new ways to fail
400 checkouts
ok 381slow 17failed 2

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:

  1. 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.
  2. 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.
  3. 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:

paymentsclient/client.go
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):

Go
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 refused
JavaScript (Node 22)
TimeoutError The operation was aborted due to timeout
TypeError fetch failed

The 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.

Practitioner§05

Who owns the data?

In one sentenceEvery table has exactly one owner. Other parts never read or write it directly; they ask the owner through its API, or listen to the events it publishes. Breaking this rule is the most common way a split goes wrong.

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:

Change one table. Who breaks?
Four services. Pick how they store data, then pick a change the owning team wants to make.
ordersowner: makes the change
Changes the table everyone reads.
payments✗ breaks
SELECT total_cents FROM orders.orders WHERE id = $1
ERROR: column "total_cents" does not exist
shipping✓ unaffected
Never read this table.
reporting✗ breaks
SELECT date(created_at), sum(total_cents) FROM orders.orders …
ERROR: column "total_cents" does not exist
Teams that must change code and deploy together: 3. The table's columns are a contract nobody wrote down — and some breaks are silent.

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:

setup — run by the migration owner, never by the app
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);
connected as orders_module (real output, Postgres 16)
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:

order-history.js
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
orders: save the order and its event in one transaction (the outbox)
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.
Practitioner§06

Building a modular monolith in Go and JavaScript

In one sentenceA modular monolith is one program, split by business capability into modules. Each module has a small public API and private tables, and the toolchain refuses code that reaches inside another module.

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

shop/ — one Go module, one binary
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.

internal/orders/orders.go
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:

packages/payments/src/index.js
{
  "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:

apps/shop/sneaky.js: import { pspCharge } from '@shop/payments/src/internal/psp.js'
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.js

Rules 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:

arch_test.go
// 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)
		}
	}
}
after someone adds an import of orders in shipping
--- 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"
FAIL

Try 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.

Draw a dependency. Does the build allow it?
This is the §06 design: orders declares Payer and never imports payments; main wires them. Tick an arrow to add it. Green = allowed, amber = only a test or lint rule catches it, red = the compiler, Node or Postgres refuses. Messages are real output (Go 1.24, Node 22, Postgres 16).
cmd/shop (main)ordersshippingpaymentsinternal/stripeorders.* tables
Dashed green = an event (orders publishes, shipping listens). Dashed box = a package hidden inside payments.
main passes payments into orders (as orders' Payer):
Builds fine. main (cmd/shop) is the one place allowed to import every module. orders only knows its own Payer interface; *payments.Service happens to have a matching Charge method.
Practitioner§07

Teams, deploys and contracts: Conway's law

In one sentenceSystems end up shaped like the teams that build them. So the first architecture decision is how you split the people; and services only stay independent if their contracts change in backward-compatible steps.
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

Same product, two ways to split the people
Each cell is a piece of the system, coloured by the team that owns it. Pick a feature: outlined cells must change.
web UI
API
database
checkout
Web team
Backend team
Data team
catalog
Web team
Backend team
Data team
delivery
Web team
Backend team
Data team
3 teams must change code: Web team, Backend team, Data team. They must agree on an interface, schedule the work together and release in the right order — 2 hand-offs per feature.
Notice: by layer, every feature needs all three teams. The system they build will mirror that: a web app, one backend and one database, each changed by every feature.

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:

  1. Expand. Payments accepts both names. Deploy payments. Nothing breaks: old callers still send cents.
  2. Migrate. Each caller switches to amount_cents, on its own schedule. Deploy each caller.
  3. Contract. Once logs show nobody sends cents any more, payments drops it. Deploy payments.
payments: step 1, accept both names
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.

Practitioner§08

Splitting safely: the strangler fig

In one sentenceMove one capability at a time, in small steps you can undo: proxy in front → real module inside → service beside it → shift traffic gradually → delete the old code. At no point do users notice a cut-over.

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.

Strangler fig: moving payments out of Shopfront
Six small, reversible steps instead of one big rewrite. Users never see a cut-over.
Code
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)
5
6// 2. inside the monolith: calls from the orders module
7var 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)
proxy routes
clients → monolith (direct)
monolith (shop)
orders
payments
shipping
orders code runs UPDATE payments_charges … 😬
new service
doesn't exist yet
Before. Shopfront is one program. Payments needs to become separate (say, for card-security rules), but orders code writes straight into payments' tables. We won't do a big-bang rewrite: we'll move it piece by piece, with users noticing nothing.
1 / 7

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:

proxy/main.go
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))
}
with two tiny fake backends running (on this machine)
$ 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 /cart

Why "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:

  1. 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.
  2. 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 which Payer orders gets.
Two roads into payments
During the migration, payments is reached two ways. Each has its own switch. Arrow thickness = share of that traffic.
① from outside, routed by the proxy → new service:
② from orders, routed by the flag inside the monolith → new service:
monolith (one program)outsideproxypayments moduleordersPayer?payments-svc99%1% over the network99%1%

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.)

internal/orders/flagged.go — road ②
// 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.

Practitioner§09

Smells: the distributed monolith

In one sentenceA Distributed monolith has all the costs of services (network, many deploys, hard debugging) and none of the independence. You can spot it from how changes and requests flow, not from how many repositories you have.

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

SmellYou notice…Root causeFix
Lockstep deploys"Deploy B first, then A, then C, all tonight."Contracts change in breaking waysExpand → migrate → contract (§07)
Shared tablesTwo services' code queries the same tableNo single data ownerOne owner; others use its API or events (§05)
Chatty callsOne page = 40 calls to the same serviceCalls per item, or boundary through the middle of one capabilityBatch endpoints, a local copy, or merge the services
Long synchronous chainsA waits for B, which waits for C, which waits for DEvery step demands an immediate answerUse events for anything that doesn't need an answer now
Entity servicesOrderService is just create/read/update/delete on one table; the rules live in its callersSplit by noun (data), not by capabilityMove the rules next to the data they guard
Shared domain libraryA "common" package with business rules that every service must upgrade togetherLogic copied into a library instead of owned by one serviceShare only plumbing (logging, HTTP clients); keep rules in their owner
One feature = PRs in 5 reposEvery change crosses servicesBoundaries cut across how the business changesRedraw 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:

Order page with 6 items: one call per item vs one batched call
chatty: a call per item
orders
load
GET /stock/1
/2
/3
/4
/5
/6
render
batched: GET /stock?skus=1,2,3,4,5,6
orders
load
one call
render
or keep a local copy: inventory publishes StockChanged events; orders keeps its own stock table
orders
load
local read
render
Each striped block is one network round trip (≈0.5 ms in the same datacenter, approximate; much more across regions). Per-item calls add up: 6 × round trip. One batched call pays it once. It's the N+1 query problem, now across the network.

The usual first fix is a batch endpoint: one request carries every ID.

inventory: one call for many SKUs
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§10

Staff-level: deciding, running, and merging back

In one sentenceSplit for a specific reason you can name, only when you can afford to run the result, one service at a time — and be willing to merge back when a split doesn't pay.

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.

Should we split this out?
Tick what is true today, not what you hope will be true.
Do we have a reason?0/4
Can we afford it?0/5
Stay a (modular) monolith. Nothing here needs a network boundary — invest in module boundaries instead.
Missing from both lists on purpose: “it's what big companies do”, “it'll look good on my CV”, and “the code is a mess”. A mess split into services is a distributed mess.

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.

CostGrows withWhat to estimate
Fixed ops per servicenumber of servicesengineer-hours per month per service (upgrades, alerts, pipeline fixes)
Idle capacitynumber of servicesminimum copies × machine size, even at zero traffic
Network overheadcalls per request × trafficCPU spent on JSON and TLS; in the cloud, traffic between availability zones is often billed per GB
Coordinationcross-service changeshours spent on contract changes, migrations and joint releases
Benefitblocked teams, different scaling needshours 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.

Practitioner§11

Pitfalls gallery

#1

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.

#2

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.

#3

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.

#4

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.

#5

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

#6

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.

#7

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).

#8

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.

#9

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).

Practitioner§12

Check yourself

Commit to an answer before you click. Each explanation points back to the section that covers it.

QUESTION 1 / 6
Compounding failures

A checkout calls 8 services one after another. Each one answers successfully 99.9% of the time, independently. Roughly what fraction of checkouts fail?

QUESTION 2 / 6
Which split is a distributed monolith?

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?

QUESTION 3 / 6
Go's internal/
// module example.com/shop
// file: internal/shipping/ship.go
package shipping

import "example.com/shop/internal/payments/internal/stripe"
QUESTION 4 / 6
Strangler fig order

You want to move payments out of a monolith. Which order of steps is the safe one?

QUESTION 5 / 6
Conway's law

A company has a frontend team, a backend team and a database team. What architecture will they most likely end up with?

QUESTION 6 / 6
Does a retry fix it?

The orders service calls POST /charges on payments. The request times out after 2 seconds. What do you actually know?

Answered 0 / 6
Foundations§13

Cheat sheet

🍽️
Restaurant vs food courtOne program with stations, or separate stalls. Stalls buy independence and pay with walking, waiting and “what now?”.
📞
Function call → network call≈2 ns vs ≈50 µs even on loopback (measured, §04). Three outcomes: yes, no, don't know.
✖️
Failures multiplyN required calls at 99.9% each succeed 0.999N of the time. Slow calls compound the same way.
🗄️
One owner per tableOthers use the owner's API or events. A schema + role per module enforces it in one Postgres.
🧱
Modular monolith by defaultModules by capability, small public APIs, boundaries the build enforces: internal/, exports maps, arch tests.
👥
Conway's lawThe system mirrors the teams. Shape teams around capabilities; one owning team per service.
🔁
Change contracts in stepsExpand → migrate → contract. Contract tests make it automatic.
🌿
Strangler figProxy in front → real module → service beside it → shift traffic behind a flag → delete the old code.
🚩
Distributed-monolith smellsLockstep deploys, shared tables, chatty calls, long synchronous chains, entity services.
🧾
Split for a reason you can runA named reason + pipelines, tracing, on-call. Merge back when a split doesn't pay.
If you need…GoJavaScript
Hide a module's internalspayments/internal/… folder"exports": { ".": "./src/index.js" }
Depend on another modulesmall interface in the caller (type Payer interface)pass an object with the method ({ payer })
Forbid module A → module Barchitecture test over go listESLint no-restricted-imports, dependency-cruiser
Forbid reading another module's tablesPostgres schema + role per modulesame
Timeout on a service callhttp.Client{Timeout: 2 * time.Second} or a context deadlinefetch(url, { signal: AbortSignal.timeout(2000) })
Route one path to a new servicehttputil.ReverseProxy + mux.Handle("/payments/", …)http.request(…) + pipe, or nginx / Envoy
Rename an API field safelyaccept both names, then remove the oldbody.amount_cents ?? body.cents
Should we split it out?
Yes, ifseveral teams block each other's releases; one part scales or fails very differently; one part needs stricter isolation
And only ifthe 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"
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Software ArchitectureDomain-Driven Design
Read →