All lessons
ArchitectureAdvanced15 min readIn my stack

Monolith vs microservices

How to split a system, when to split it, and why most teams should start with a modular monolith.

  • Monolith
  • Modular monolith
  • Microservices
  • Boundaries
Step 1/4
A monolith is one big app that does everything: orders, users, payments. Simple to build and run.

In 30 seconds

A monolith is one application you build and deploy as a single unit. Microservices are many small applications, each owning its own data, talking over the network. A modular monolith sits in between: one deployable, but with strict internal boundaries. The right choice depends far more on team size and domain clarity than on traffic.

Why I use it

  • I use a modular monolith by default because it gives clear boundaries without paying the network, deployment and observability tax of microservices on day one.
  • Most of the pain I see in young products comes from tangled code, not from a lack of services. Good module boundaries fix that; splitting into services usually does not.
  • Choosing the architecture is a business decision too: microservices multiply hosting, monitoring and on-call costs, and clients deserve to hear that before they pay for it.
  • When a split really is needed, well-drawn module boundaries make it a mechanical extraction instead of a rewrite.

How it works

01Three shapes: monolith, modular monolith, microservices

Think of a restaurant. A monolith is one kitchen where every cook can grab any ingredient from any shelf: fast at first, chaotic later. A modular monolith is the same kitchen split into stations (grill, pastry, salads), each with its own shelves and a counter where orders are handed over. Microservices are separate restaurants that send each other orders by courier: each can open, close or renovate independently, but every handover now takes time and can get lost. In software terms, a monolith is one codebase, one process type and usually one database. A modular monolith keeps one deployable but divides code into modules with public interfaces, and each module owns its tables. Microservices are independently deployable processes, each with its own datastore, communicating through HTTP, gRPC or messages.

  • Monolith: simplest to build, test, deploy and debug; risk is a 'big ball of mud'.
  • Modular monolith: one deploy, enforced boundaries, in-process calls, one transaction when you need it.
  • Microservices: independent deploys and scaling per service; cost is distributed-systems complexity.

02The real costs of microservices

Microservices move complexity from the code into the network. A function call that never failed becomes a request that can time out, arrive twice or succeed without you hearing about it. A single database transaction becomes a distributed workflow that needs sagas, outboxes and idempotency. Data ownership becomes strict: the orders service may not read the customers table, so you need APIs, events or local copies, and those copies can be stale. Debugging needs distributed tracing, correlated logs and per-service dashboards, or a single slow checkout becomes a detective story. Deployment needs CI pipelines, versioned contracts, service discovery and usually an orchestrator. Every service also needs its own monitoring, alerts, secrets and upgrades. None of this is impossible, but it is a permanent tax you pay every week.

  • Distributed transactions: no shared ACID transaction across services.
  • Network failures: timeouts, retries, partial failures, cascading outages.
  • Data ownership: no cross-service joins; duplicated, eventually consistent data.
  • Observability and cost: tracing, more infrastructure, more on-call surface.

03Finding boundaries with bounded contexts

The hardest part of any split is where to cut. Domain-Driven Design gives a practical tool: the bounded context, an area of the business where words have one precise meaning. 'Product' in the catalogue means a description with photos; in inventory it means stock in a warehouse; in billing it means a price line. Each context gets its own model and its own module. Good boundaries have high cohesion inside (things that change together live together) and low coupling outside (a narrow, stable interface). A useful test: can a team change this module for a week without coordinating with anyone? If not, the boundary is wrong. Cut by business capability (ordering, payments, loyalty), never by technical layer (a 'database service' or a 'validation service'), which only creates chatty, fragile dependencies.

ts
// eslint.config.mjs: modules may import each other only through a public index
export default [
  {
    files: ["src/modules/**/*.ts"],
    rules: {
      "no-restricted-imports": ["error", {
        patterns: [{
          group: ["@/modules/*/internal/*", "@/modules/*/db/*"],
          message: "Use the other module's public index.ts, not its internals.",
        }],
      }],
    },
  },
];

04Building a modular monolith that stays modular

Boundaries that live only in a diagram erode within months. Enforce them in code. Give each module a folder with a public entry point (services, DTOs, events) and keep everything else internal. Let each module own its tables and forbid other modules from querying them directly; cross-module reads go through the owning module's service. Use in-process domain events for side effects ('order paid' triggers loyalty points) so modules do not call each other in long chains. Add lint rules or architecture tests that fail the build on forbidden imports. In frameworks with a module system, such as NestJS, export only what other modules may use. The payoff: when one module truly needs to scale or deploy separately, you already have its interface, its data and its events, and extraction becomes a project of weeks rather than a rewrite.

05Migrating with the strangler fig pattern

When an existing monolith must be split, never attempt a big-bang rewrite: it freezes features for months and usually fails. Use the strangler fig pattern, named after a plant that slowly grows around a tree. Put a routing layer (a reverse proxy or API gateway) in front of the monolith. Pick one well-bounded capability, build it as a new service, and route only its endpoints there while everything else still hits the monolith. Keep data in sync during the transition with events or change data capture, then move ownership of the data to the new service. Repeat one capability at a time, and delete the old code after each step. Each step is small, reversible and shippable, so the business keeps moving while the architecture changes underneath.

  • Start with a capability that has clear data ownership and few inbound dependencies.
  • Run old and new paths in parallel and compare results before switching traffic.
  • Measure: if the new service does not make deploys or scaling easier, stop.

06Choosing: team size, domain and scaling needs

Conway's law says systems mirror the communication structure of the teams that build them. One or two small teams gain almost nothing from microservices, because they coordinate easily anyway, while they pay the full operational cost. Microservices start paying off when several autonomous teams block each other on a shared release, when parts of the system have very different scaling or reliability needs (a video encoder versus an admin panel), or when compliance demands hard isolation. Traffic alone is rarely the reason: a well-built monolith scales horizontally behind a load balancer to very large loads. A sensible default for most companies is to start with a modular monolith, invest in boundaries, tests and observability, and extract a service only when a concrete, measured pain justifies it.

Use it when

  • Modular monolith: new products, teams up to a few dozen engineers, or domains still being discovered.
  • Microservices: several independent teams blocked by a shared release cycle.
  • Microservices: components with very different scaling, runtime or reliability needs.
  • Extraction of one service: a hot path (search, media processing) that needs separate scaling.

Skip it when

  • Do not choose microservices to look modern or because a large tech company uses them.
  • Do not split when boundaries are still unclear; wrong boundaries are far costlier across a network.
  • Do not split a small team's product into more services than there are engineers.

Alternatives

  • Plain monolith

    Fine for small apps and prototypes; add module boundaries once the codebase grows.

  • Modular monolith

    One deployable with enforced internal boundaries; the best default for most teams.

  • Service-based architecture

    A few coarse-grained services (e.g. core app, search, media) instead of dozens of tiny ones.

  • Serverless functions

    Good for spiky, event-triggered jobs; harder for long workflows and local debugging.

Best practices

  1. Cut boundaries by business capability, using bounded contexts, not by technical layer.
  2. Give every module or service exclusive ownership of its data.
  3. Enforce boundaries automatically with lint rules or architecture tests.
  4. Communicate side effects through events, not long chains of synchronous calls.
  5. Invest in logs, metrics and tracing before you split anything.
  6. Extract services one at a time with the strangler fig pattern, and keep each step reversible.

Common mistakes

  • Building a 'distributed monolith': services that must deploy together and share a database.
  • Splitting too early, before the domain is understood, then paying to merge services back.
  • Making synchronous call chains (A calls B calls C), so one slow service takes everything down.
  • Forgetting that every service multiplies CI, monitoring, secrets and on-call work.

Deep dive

07Coupling you cannot see: temporal, data and deployment

Import rules catch code coupling, but three quieter kinds decide whether a split works. Temporal coupling: service A can only finish its request while B is up, because it calls B synchronously. Availability then multiplies: three services at 99.9 percent each, chained, give roughly 99.7 percent for the whole flow. Data coupling: two modules read or write the same tables, so a schema change in one breaks the other without any import between them. Deployment coupling: a change needs coordinated releases because a contract changed in a non-backward-compatible way. Measure these before splitting. Look at which tables each module touches, at the call graph from tracing, and at which folders change in the same commits over a few months (change coupling from Git history). Modules that keep changing together belong together, whatever the diagram says.

  • Chained availability: multiply the SLOs of every synchronous hop.
  • Shared tables are a boundary violation even with clean imports.
  • Git co-change analysis reveals real coupling cheaply.

08Contracts between services: versioning and consumer-driven tests

Once a boundary becomes a network boundary, its interface is a published contract and independent deployment depends on keeping it stable. Follow the tolerant reader rule: consumers ignore fields they do not know, producers only add optional fields. Breaking changes go through expand and contract: add the new field or endpoint, migrate consumers, then remove the old one after usage drops to zero. Consumer-driven contract tests (with a tool such as Pact) let each consumer record the requests and response fields it relies on; the provider's CI replays those expectations and fails before a breaking change ships, without spinning up the whole system. This replaces slow, flaky end-to-end environments as the main safety net. Version events the same way, and keep a written owner for every API so questions have somewhere to go.

09Extraction mechanics: branch by abstraction and the anti-corruption layer

Inside the monolith, branch by abstraction prepares a module for extraction without a long-lived branch. Introduce an interface for the capability, route all callers through it, then add a second implementation that calls the new service, switched by a feature flag per tenant or percentage. Roll forward gradually and keep the in-process implementation as a fallback until the remote one is proven. Where the new service has a cleaner model than the legacy code, put an anti-corruption layer between them: a translator that maps old concepts to new ones so legacy naming and quirks do not leak into the new design. Data moves in phases: the new service first reads a replicated copy, then becomes the writer while the monolith reads from it, and finally the old tables are dropped.

ts
// Branch by abstraction: callers depend on the interface, not the location
export interface PricingPort {
  quote(cartId: string): Promise<Quote>;
}
 
export function pricingFor(tenantId: string, deps: Deps): PricingPort {
  return deps.flags.isOn("pricing-service", tenantId)
    ? new RemotePricing(deps.http)      // new service, behind timeouts and retries
    : new InProcessPricing(deps.db);    // existing module, kept as fallback
}

10Team topology and the inverse Conway manoeuvre

If systems mirror team communication, you can design the organisation to get the architecture you want: the inverse Conway manoeuvre. Give each stream-aligned team end-to-end ownership of one or a few bounded contexts, including its data, deploys and on-call. A platform team provides paved roads (CI templates, observability, service scaffolding) so every new service does not reinvent them. Watch cognitive load: a team that owns more services than it can reason about will cut corners on all of them. A rough sanity check is the ratio of deployables to engineers; when it goes past one, operations start to dominate feature work. Re-draw boundaries when the team structure changes, because a service owned by three teams becomes a coordination bottleneck, and an orphaned one quietly rots.

Real-world scenarios

01

A five-person team wants to rewrite into microservices

Problem

A product built over four years has become hard to change; every feature touches many files and releases often break unrelated screens. The team proposes rewriting it as twelve microservices.

How to solve it

Push back on the rewrite and fix the coupling first. Map bounded contexts with the team in a short workshop, then reorganise the code into modules with public entry points and lint rules that forbid deep imports. Assign table ownership per module and replace cross-module queries with service calls or events. Add tracing and characterisation tests on the riskiest flows. After a few months, measure: deploy frequency, change failure rate and how many modules a typical feature touches. Only if one module still needs separate scaling or release cadence, extract that single module with the strangler fig pattern.

02

Checkout latency doubles after a split

Problem

After extracting pricing, inventory and promotions into services, checkout p95 latency went from 300 ms to 700 ms and failures spike whenever inventory deploys.

How to solve it

Trace one checkout end to end and you will usually find sequential synchronous calls, each with its own retries. Fan independent calls out in parallel, set strict timeouts below the caller's own budget, and retry only idempotent calls with jittered backoff. Keep a local read model of slow-changing data (prices, promotion rules) updated by events instead of calling for it per request. Add a circuit breaker so an inventory deploy degrades to 'availability confirmed later' rather than failing checkout. Use rolling deploys with readiness checks. If two services are always called together, merge them back.

03

Extracting a heavy media-processing module

Problem

Image and video processing in the monolith eats CPU during uploads and slows the API for everyone. It is the only part with very different resource needs.

How to solve it

This is a good extraction candidate: narrow interface, clear data ownership and different scaling. Define the contract first: the API stores the original in object storage and publishes a 'media uploaded' job with an ID and storage key. A separate worker service, scaled on queue depth, processes it and emits 'media processed' with the variants. The API updates its records idempotently from that event. Ship it behind a flag, run both paths on a sample, compare outputs, then switch fully and delete the in-process code.

Cheat sheet

Forbid deep imports between modules
ESLint no-restricted-imports on '@/modules/*/internal/*'
Find modules that change together
git log --name-only --since=6.months --format='' | sort | uniq -c | sort -rn
Availability of a synchronous chain
0.999 x 0.999 x 0.999 ≈ 0.997 (about 2 h/month down)
Safe contract change
Expand (add optional) -> migrate consumers -> contract (remove)
Consumer-driven contract testing
Pact: consumer publishes pact, provider CI verifies it
Gradual extraction switch
Feature flag per tenant: in-process vs remote implementation
Strangler routing at the edge
location /api/billing/ { proxy_pass http://billing; }
Sanity ratio
Deployables per engineer below 1; above it ops dominates

In my real projects

  • The multi-tenant POS SaaS I co-lead is a pnpm + Turborepo monorepo: one NestJS API plus a worker, organised into 83 domain modules, alongside 6 apps and 10 shared packages.
  • Cross-module side effects there flow through a transactional outbox into BullMQ queues and handlers, instead of modules calling each other directly.
  • On the restaurant ordering platform, one Laravel API serves web, mobile and admin, with controllers split into Shared/Ui/Mobile/Admin so every channel reuses the same core logic.
  • On the pet-supplies platform, roughly 90 models live in 12 domain folders with about 110 service classes, which keeps the monolith navigable.

Questions & answers

Q1When would you recommend microservices to a client?

When there is a concrete pain that only independent deployment solves: several teams blocking each other on one release train, a component with very different scaling or runtime needs, or a compliance requirement for hard isolation. I would also check whether they can afford the platform work: CI per service, tracing, on-call, contract versioning. If the team is small or the domain is still moving, I recommend a modular monolith with strict boundaries, because it delivers most of the organisational benefit at a fraction of the cost, and it can be split later along the same lines.

Q2What is a distributed monolith and how do you avoid it?

It is a system deployed as many services that still behaves like one: services share a database, must be released together, or call each other synchronously in long chains so one failure breaks everything. You get the costs of both worlds and the benefits of neither. Avoid it by giving each service exclusive data ownership, communicating side effects through events, versioning contracts so services deploy independently, and drawing boundaries around business capabilities rather than technical layers. A simple check: if changing one feature regularly needs coordinated deploys of three services, the boundaries are wrong.

Q3How do you decide service or module boundaries?

I start from the business, not the code. Using Domain-Driven Design, I look for bounded contexts: areas where a term like 'order' or 'product' has one precise meaning and a clear owner. Then I check cohesion and coupling: things that change together should live together, and the interface between areas should be narrow and stable. Data ownership is the strongest signal; if two candidates constantly need each other's tables, they are probably one context. I validate boundaries inside a modular monolith first, where moving them is cheap, before turning any of them into network boundaries.

Q4How would you migrate a legacy monolith to services?

Incrementally, with the strangler fig pattern. First I add observability and tests around the monolith, then put a routing layer in front of it. I pick one capability with clear boundaries and few dependencies, build it as a new service, sync data through events or change data capture, and switch its routes over, ideally after running both paths in parallel and comparing results. Once stable, the new service becomes the owner of that data and the old code is deleted. Each step is small and reversible. A big-bang rewrite stops feature work for months and rarely finishes.

Q5How do you handle a transaction that spans two services?

You accept that there is no shared ACID transaction and design for it. Each service commits its own local transaction and publishes an event through a transactional outbox, so the state change and the message are saved atomically. The workflow is coordinated as a saga, either orchestrated by one component or choreographed through events, with a compensating action for each step, such as refunding a payment if stock reservation fails. Consumers must be idempotent because messages can arrive more than once. If a workflow needs this constantly, that is a hint the two services should be one.

Q6Does a monolith scale?

Yes, much further than people assume. A stateless monolith runs as many identical instances behind a load balancer, with sessions in Redis, files in object storage and a database with read replicas and caching. Many very large products ran on monoliths for years. What a monolith does not give you is independent scaling of one hot component or independent deployment by separate teams. If one part, such as image processing or search, needs different resources, extract just that part. Scaling is usually a database and caching problem long before it is an architecture problem.

Senior-level questions

S1How do you keep data consistent and queryable once each service owns its database?

Each service is the single writer for its data, and others get it in one of three ways. For fresh, decision-critical reads, call the owner's API with a timeout and a fallback. For frequent reads of slow-changing data, keep a local read model updated by events from the owner, accepting seconds of staleness. For reporting across services, stream changes into a separate analytics store rather than joining live databases. Cross-service writes become sagas with compensations and outbox-published events. The trade-off is explicit: you gain independent deployment and scaling, and you pay with eventual consistency, duplicated data and more code. If a feature constantly needs atomic writes across two services, that is evidence the boundary is wrong.

S2What metrics would tell you an architecture change actually helped?

I use the four DORA metrics as the baseline: deployment frequency, lead time for changes, change failure rate and time to restore service. A good split should raise frequency and shorten lead time for the teams involved without raising change failures. I add architecture-specific signals: how many modules or services a typical feature touches, how often deploys need coordination, p95 latency and error rate of the key user flows, and infrastructure and on-call cost per team. Measure for a few months before and after. If deploys did not get more independent and incidents rose, the change added cost without benefit, and merging back is a legitimate, professional decision.

S3Synchronous calls or events between services: how do you decide per interaction?

I ask whether the caller needs the answer to continue. Queries that decide the user's next screen, such as a price or an authorisation check, are synchronous, protected by timeouts, retries on idempotent operations and circuit breakers. Side effects that can happen after the response, such as sending receipts, updating loyalty or reindexing search, are events, which remove temporal coupling and let consumers be down without hurting the producer. Commands to a specific service that may take long go through a queue with a status the client can poll. The cost of events is harder debugging and eventual consistency, so I insist on correlation IDs, tracing across queues and dead-letter monitoring before I lean on them heavily.

Glossary

Bounded context
A part of the domain where every term has one precise meaning and one model.
Distributed monolith
Many services that must still deploy and change together, combining the costs of both styles.
Strangler fig
Replacing a legacy system piece by piece by routing traffic to new components gradually.
Branch by abstraction
Introducing an interface so a new implementation can replace an old one behind a switch.
Anti-corruption layer
A translator that keeps a legacy model's concepts from leaking into a new design.
Conway's law
Systems tend to mirror the communication structure of the organisation that builds them.
Tolerant reader
A consumer that ignores unknown fields, so producers can add data without breaking it.
Consumer-driven contract
Tests where consumers declare what they rely on and the provider verifies it in CI.
Temporal coupling
When one component can only work while another is available at the same moment.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.