The answer in one breath: a monolith is one deployable application, where modules call each other in-process and usually share one database. Microservices split the system into small services, each owned by a team, deployed on its own and owning its own data, communicating over the network. Microservices buy you independent deployment, independent scaling and fault isolation, and they cost you network latency, partial failures, distributed data and a lot of operational tooling. A modular monolith sits between them: one deployable, but with modules whose boundaries are enforced as strictly as service boundaries would be.
For most new products and small teams, the strong default is a well-structured monolith, splitting later where there’s evidence. Martin Fowler’s “MonolithFirst” essay makes that case, and it’s a safe position to argue in an interview as long as you also know what would change your mind.
Comparing the three architectures
| Monolith | Modular monolith | Microservices | |
|---|---|---|---|
| Deployables | One | One | One per service |
| Calls between parts | Function calls | Function calls through module APIs | Network calls (HTTP, gRPC, messaging) |
| Data | One shared database | One database, schema or tables owned per module | Database per service |
| Transactions across parts | Ordinary local transactions | Local transactions (if modules allow) | Sagas, outbox, eventual consistency |
| Scaling | Whole app scales together | Whole app scales together | Each service scales on its own |
| Team autonomy | Low; shared release train | Medium; clear ownership, shared release | High; teams release independently |
| Failure isolation | A crash or memory leak can take down everything | Same as monolith | One service can fail while others degrade gracefully |
| Operational cost | Lowest | Low | High: service discovery, tracing, CI per service, on-call per service |
| Refactoring across boundaries | Easy (IDE rename) | Moderate | Hard: versioned contracts between teams |
| Typical fit | Early product, small team | Growing product, several teams, one platform team | Many teams, distinct scaling or reliability needs |
What a network hop really costs
The trade-offs above sound abstract until you put numbers on them. Two measurements, both in Node 22 on one laptop.
First, latency. The same getPrice function was called directly and through a tiny HTTP server on 127.0.0.1, 2,000 times each:
const getPrice = (sku) => ({ sku, cents: 1999 });
// ...an http.createServer that returns getPrice(...) as JSON...
for (let i = 0; i < N; i++) getPrice('book');
for (let i = 0; i < N; i++) await (await fetch(url)).json();in-process: 0.08 µs per call
HTTP on localhost: 1580 µs per call
ratio: ~19666xA second run gave 1,531 µs. The exact figures depend on hardware and the HTTP client, so don’t memorise them, but the gap of roughly four orders of magnitude is the point. Real networks add serialisation of bigger payloads, TLS, load balancers and cross-zone hops. A page that makes 30 small module calls costs nothing in a monolith. As 30 sequential service calls, it’s noticeably slow, so microservice designs work hard to batch, parallelise or cache those calls.
Second, availability. If a request needs N services in sequence, and each is independently up 99.9% of the time, the request only succeeds when all of them are:
for (const n of [1, 3, 5, 10]) {
const a = 0.999 ** n;
const minutesDown = (1 - a) * 30 * 24 * 60;
console.log(`${n} services: ${(a * 100).toFixed(2)}% -> ~${Math.round(minutesDown)} min/month failing`);
}1 services: 99.90% -> ~43 min/month failing
3 services: 99.70% -> ~129 min/month failing
5 services: 99.50% -> ~216 min/month failing
10 services: 99.00% -> ~430 min/month failingTen services each meeting “three nines” give you a request path at two nines: about seven hours a month of failures. Real failures aren’t perfectly independent, so treat this as a model, not a forecast. It still explains why microservice systems need timeouts, retries with budgets, circuit breakers and fallbacks, and why asynchronous messaging is preferred for anything that doesn’t need an immediate answer. A monolith has the opposite problem: fewer moving parts, but one bad deploy or one runaway query affects everything.
A walkthrough: an online shop growing up
Picture a shop built by six engineers as a monolith with modules for catalogue, cart, orders, payments and shipping, all in one Postgres database. Deploys happen twice a day, everyone understands the code, and checkout is a single local transaction.
Two years later there are 60 engineers in eight teams. The symptoms that suggest a split look like this, and they’re the ones to name in an interview:
- Release coordination hurts. One team’s broken migration blocks seven teams’ deploys. The deploy queue becomes the bottleneck.
- Scaling needs diverge. Search and catalogue reads spike 20x during a sale, while payments traffic stays modest but must never fail. Scaling the whole app for search is wasteful.
- Different reliability or compliance needs. Payments must meet PCI DSS. Isolating card handling into its own service shrinks the audit scope.
- Boundaries keep leaking. Shipping code reads order tables directly, so changing the order schema breaks shipping.
Notice that “the codebase has 500,000 lines” isn’t on the list. Size alone is a tooling problem (build caching, ownership files, module rules), not an architecture one.
The path that usually works is gradual. Make the monolith modular first: give each module a public interface, forbid imports of another module’s internals (tools like ArchUnit for Java or ESLint boundary rules for TypeScript can enforce this in CI), and give each module ownership of its own tables so nothing reads another module’s data directly. Then extract the one service with the clearest reason, here perhaps payments, behind the interface that already exists. The incremental migration guide covers the extraction steps, including moving data ownership.
Mistakes that interviewers notice
The distributed monolith. Services that share a database, or must be deployed together because they call each other synchronously in long chains, have the costs of microservices and the coupling of a monolith. If changing one feature means coordinated deploys of four services, the boundaries are wrong.
Splitting by technical layer. A “database service”, a “validation service” and a “UI service” put every feature change across all of them. Split by business capability (orders, payments, catalogue) instead, as service boundaries explains.
Ignoring data. The hardest part of a split isn’t the code, it’s the shared tables and the transactions across them. Checkout that used to be one transaction becomes a saga with compensating actions when orders and payments have separate databases.
Assuming microservices are required for scale. Many large products run on monoliths scaled horizontally behind a load balancer, with read replicas and caches. Scale is a reason to split only when parts of the system need different scaling.
Underestimating the platform. Each service needs CI, deployment, monitoring, logging, tracing, alerting, secrets and an owner on call. Fowler’s “Microservice Premium” essay describes this as a cost you only want to pay when the system’s complexity justifies it.
Interview practice: advise a startup
A seed-stage startup with five engineers is building a B2B scheduling product. The CTO wants microservices “so we can scale later”. What do you recommend?
Answer, explained
Recommend a modular monolith. With five engineers, there’s no team coordination problem for microservices to solve, and the team would spend a large share of its time on infrastructure (service discovery, distributed tracing, per-service pipelines, contract versioning) instead of finding product-market fit. Domain boundaries are also likely to change in the first year, and moving a boundary inside a monolith is a refactor, while moving it between services is a migration. Protect the future option instead: organise code by business module (scheduling, billing, notifications, accounts) with enforced public interfaces, keep each module’s tables private to it, and use an in-process event bus for side effects like sending emails, so the code is already shaped for asynchronous messaging. Then list the signals that would justify extracting a service later: a module needing very different scaling, a compliance boundary such as payments, or separate teams blocked by a shared release. That answer shows judgement rather than a preference.