- Microservices are an organisational scaling tool more than a technical one. They let many teams deploy independently, at the cost of network calls, distributed data, and a large operations bill.
- The right default for most products and teams under ~30–50 engineers is a modular monolith: one deployable, strict module boundaries, each module owning its tables.
- Extract a service when there is a concrete reason: independent scaling, different reliability or tech needs, or teams blocking each other.
- Draw boundaries around business capabilities / bounded contexts, never around technical layers or database tables. Bad boundaries produce a distributed monolith.
- Migrate with the strangler fig pattern, one capability at a time, with data ownership moving along with the code.
"Should this be microservices?" is one of the most expensive questions in software, and it is usually answered by fashion rather than by analysis. Microservices solved real problems at companies with thousands of engineers. They also created real problems at companies with fifteen. This guide lays out what each architecture really costs, a decision framework you can apply, how to find boundaries that will not need redrawing, and what publicly documented migrations (in both directions) teach.
Monolith, Modular Monolith, Microservices: Definitions That Matter
Monolith ("big ball of mud" variety)
One codebase, one deployable, one database. Any code can call any other code and read any table. Simple to start; becomes hard to change as coupling grows.
Modular monolith
One deployable, but organised into modules with enforced boundaries: public interfaces, private internals, each module owns its tables. In-process calls, one deployment, clear seams for later extraction.
Microservices
Many independently deployable services, each owning its data, communicating over the network (HTTP/gRPC or messaging). Each is typically owned by one team.
The important distinction is not "one process vs many". It is coupling. A well-modularised monolith is far easier to evolve than a set of services that share a database and must be deployed in lockstep. That last thing has a name, the distributed monolith, and it is the worst of both worlds.
The Real Costs of Microservices
Articles listing "pros and cons" usually understate the costs, because those costs are spread across many teams and show up months later. Here is what you sign up for.
1. The network is now in every call path
An in-process call takes nanoseconds and cannot partially fail. A network call takes on the order of a millisecond inside a data centre, can time out, can succeed on the server while the client sees a failure, and can be retried into a duplicate. A request that fans out through 5 services sequentially pays that latency and failure probability 5 times. If each hop has 99.9% success, a 5-hop chain succeeds about 99.5% of the time before retries. Every call now needs timeouts, retries with backoff, idempotency, and circuit breakers.
2. Distributed data and lost transactions
Each service owns its database, so operations spanning services lose ACID guarantees. "Create order and reserve inventory atomically" becomes a saga with compensations, an outbox, and idempotent consumers (see saga pattern and distributed transactions). Reporting that used to be a SQL join now requires a data pipeline into a warehouse.
3. Operational overhead per service
Every service needs: a CI/CD pipeline, container images, deployment config, autoscaling settings, dashboards, alerts, log routing, on-call ownership, dependency upgrades, security patching, secrets, and documentation of its API. Multiply by the number of services. Platform teams exist to amortise this cost, and you need one sooner than you think.
4. Observability becomes mandatory
"Why was this checkout slow?" used to be one stack trace. Now it needs distributed tracing with propagated trace IDs across every hop, correlated logs, and per-service metrics (see observability: logs, metrics and tracing).
5. Testing and local development get harder
Running the system on a laptop may require a dozen containers. End-to-end tests become slow and flaky. Teams move to contract testing (Pact) and test environments per branch, which are themselves infrastructure to maintain.
6. Changes that cross services are slow
If a feature touches three services owned by three teams, it now needs coordinated API changes, backwards-compatible rollout, and three release trains. Microservices make changes within a boundary faster and changes across boundaries slower. That is why boundaries matter so much.
7. Infrastructure cost
Each service has baseline resource usage (JVM heap, sidecars, minimum replicas for availability). Twenty services each running three replicas for redundancy is 60 pods before any real load. Serialization and network traffic add CPU and bandwidth costs.
The Real Benefits (When They Apply)
None of this means microservices are a mistake. They are the right tool when these benefits are worth the costs:
- Independent deployment for many teams. With 300 engineers in one monolith, the release train becomes the bottleneck: one team's broken test blocks everyone's release. Services let each team deploy many times a day without coordination.
- Independent scaling. During IPL, a streaming platform's playback and live-score services need to scale by orders of magnitude while account settings barely change. Scaling only the hot path is cheaper (see Hotstar live streaming design).
- Fault isolation. A memory leak in the recommendations service should not take down payments. In a monolith, every module shares a process and its fate (bulkheads within a monolith help but are weaker).
- Technology fit. A Go service for a high-throughput gateway, Python for ML inference, Java for core business logic.
- Different compliance or security zones. Payment card data handling (PCI DSS) is easier to scope when it lives in a small isolated service.
- Ownership clarity. A service with one owning team is easier to keep healthy than a shared module everyone touches.
Notice that most benefits are about people and organisation. That is Conway's Law in action: systems end up mirroring the communication structure of the organisations that build them. Microservices work when service boundaries match team boundaries.
The Modular Monolith: The Default for Most Teams
A modular monolith captures most of the design benefits of microservices (clear ownership, explicit interfaces, isolated data) without the distributed systems tax. Shopify has written publicly about choosing to modularise its large Rails monolith rather than split it into services, and built a tool (Packwerk) to enforce module boundaries.
Rules that make a monolith modular
- Modules map to business capabilities:
ordering,payments,catalog,delivery. Notcontrollers,services,repositories. - Each module exposes a small public API (interfaces, DTOs). Everything else is internal.
- Each module owns its tables. No other module queries them directly. Ideally separate schemas in the same database.
- Cross-module communication goes through the API or in-process events, not shared entities.
- Boundaries are enforced by tooling, not good intentions: Java modules (JPMS), ArchUnit tests, Spring Modulith, Packwerk, Nx module boundaries, or separate Gradle/Maven modules.
What it looks like in Java
com.example.shop
├── ordering
│ ├── api
│ │ ├── OrderingApi.java // public interface
│ │ └── OrderPlaced.java // public event record
│ └── internal // not accessible to other modules
│ ├── OrderService.java
│ ├── OrderRepository.java // owns schema "ordering"
│ └── OrderEntity.java
├── payments
│ ├── api
│ │ └── PaymentsApi.java
│ └── internal
│ └── ...
└── catalog
├── api
└── internal
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.domain.JavaClasses;
import org.junit.jupiter.api.Test;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
class ModuleBoundaryTest {
private final JavaClasses classes =
new ClassFileImporter().importPackages("com.example.shop");
@Test
void paymentsMustNotTouchOrderingInternals() {
noClasses().that().resideInAPackage("..payments..")
.should().dependOnClassesThat().resideInAPackage("..ordering.internal..")
.check(classes);
}
@Test
void orderingMustNotTouchPaymentsInternals() {
noClasses().that().resideInAPackage("..ordering..")
.should().dependOnClassesThat().resideInAPackage("..payments.internal..")
.check(classes);
}
}
Inside ordering, placing an order calls paymentsApi.authorize(...), an in-process interface call. If payments later becomes a service, you replace the implementation of PaymentsApi with an HTTP or gRPC client and the calling code barely changes. The seams are already there.
Decision Framework: Monolith or Microservices?
Ask these questions in order. The first few "no" answers usually settle it.
| Question | If yes | If no |
|---|---|---|
| Do you have product-market fit and a reasonably stable domain? | Boundaries can be drawn with confidence | Monolith: boundaries will move as you learn |
| Are there more than ~4–6 teams working on the same codebase, blocking each other's releases? | Services can decouple them | Modular monolith |
| Does one component have very different scaling or load patterns? | Extract that component | Scale the monolith horizontally |
| Does one component need different reliability, security or compliance isolation? | Extract it | Keep it in |
| Do you have platform capabilities (CI/CD templates, container orchestration, tracing, on-call)? | You can absorb the operational cost | Build these first or stay monolithic |
| Is a component a natural fit for a different language or runtime (ML, streaming)? | Separate service makes sense | Same runtime is fine |
A rough maturity path
- Seed to early growth (1–15 engineers): one deployable, modular from day one. Separate only truly different workloads (an async worker process, maybe an ML service).
- Growth (15–60 engineers): still mostly monolith; extract 2–5 services with clear reasons (payments, notifications, search, media processing).
- Scale (many teams): services aligned to teams and domains, a platform team, standard templates, API gateway, service mesh if needed.
The headcount figures are rough guides, not laws; a 10-person team building a data-intensive platform may legitimately need several services.
Decomposing by Bounded Context (Finding Service Boundaries)
The most consequential decision is where to cut. Domain-Driven Design's bounded context is the best tool: a boundary within which a term has one consistent meaning and model.
Example: "Order" in a food delivery company
| Context | What "order" means here | Data it cares about |
|---|---|---|
| Ordering | The customer's cart turned into a purchase | Items, prices, customer, address, offers applied |
| Restaurant operations | A ticket to prepare | Items, prep time, special instructions, kitchen status |
| Delivery | A pickup and drop job | Pickup and drop locations, rider, ETA, distance |
| Payments | An amount to collect and settle | Amount, payment method, gateway reference, refunds |
| Support | A case history | Timeline, complaints, refunds issued |
A single shared Order class with 80 fields serving all five is how monoliths become balls of mud, and splitting that class into services without separating the models is how distributed monoliths are born. Each context should own its own model, sharing only IDs and the events that connect them (OrderPlaced, OrderReady, OrderDelivered). See how these contexts interact in Swiggy/Zomato system design.
Heuristics for good boundaries
- High cohesion inside, low chattiness across. If two candidate services call each other on every request, they belong together.
- A boundary should own a business capability end to end, including its data, rules and API.
- Align with teams. One team per service; a team can own several.
- Avoid entity services. "CustomerService", "ProductService" that are thin CRUD wrappers around tables force every business operation to orchestrate many calls.
- Look at change history. Files that change together in commits should live together.
Signs of a distributed monolith
- Several services read and write the same database tables.
- A typical feature requires coordinated deploys of three or more services.
- Long synchronous call chains (A → B → C → D) for a single user request.
- A shared "common" library containing domain models that every service must upgrade in lockstep.
- An outage in one non-critical service takes down the whole product.
Migrating with the Strangler Fig Pattern
Named by Martin Fowler after vines that grow around a host tree until they replace it, the strangler fig pattern migrates incrementally instead of via a big-bang rewrite (which historically has a poor track record).
- Put a routing layer in front. An API gateway or reverse proxy that can route by path, header or percentage. See API gateway design.
- Pick the first capability carefully. Something valuable, relatively self-contained, with few inbound dependencies. Notifications or search are classic first extractions; the core order flow is not.
- Create a seam inside the monolith first. Refactor so all access to that capability goes through one interface. This step is often most of the work.
- Build the new service behind the same contract.
- Move the data. Give the service its own database. Sync data during transition via CDC from the monolith's tables, then flip ownership so the service is the writer and the monolith reads via API or events. Data migration is the hardest part; do not skip it by letting the new service read the old tables forever.
- Shift traffic gradually. Shadow traffic (compare responses), then 1%, 10%, 50%, 100%, with a quick rollback switch.
- Delete the old code. Migrations that never remove the old path double the maintenance cost.
Related techniques: branch by abstraction (swap implementations behind an interface inside the monolith), an anti-corruption layer (translate between the legacy model and the new service's model so the old model does not leak in), and parallel run (run both and compare outputs for risky logic like pricing).
Case Studies: Migrations in Both Directions
These are drawn from public engineering blogs and talks; details are as those companies described them.
Netflix: monolith to microservices
Netflix has described how a major database corruption in 2008 that disrupted DVD shipping pushed it to move from a monolithic application in its own data centres to a cloud-based, service-oriented architecture on AWS, a migration that took several years. With a large engineering organisation and extreme scale variation, independent deployment and fault isolation paid off, and Netflix built much of the tooling (Eureka, Hystrix, Zuul) that popularised the pattern.
Segment: microservices back to a monolith
Segment's 2018 post "Goodbye Microservices" described splitting its event-delivery destinations into a separate service and queue per destination, growing to well over a hundred services. Shared library versions drifted across services, operational load grew with each new destination, and a small team could not keep up. They consolidated the destinations into a single service and reported major improvements in developer productivity.
Amazon Prime Video: serverless microservices to a monolith (for one component)
In 2023 the Prime Video team wrote about an audio/video quality monitoring tool built as distributed components orchestrated by AWS Step Functions with S3 for intermediate frames. Moving the pipeline into a single process removed the orchestration and data-transfer overhead, and the team reported reducing infrastructure cost for that component by over 90%. The lesson is not "microservices are bad" but that a high-volume, tightly coupled pipeline should not pay network costs between every step.
Uber: from thousands of services to domains
Uber wrote about growing to thousands of microservices and the resulting complexity in understanding dependencies. Its response, Domain-Oriented Microservice Architecture (DOMA), groups related services into domains with a single gateway each and layers that restrict dependencies: essentially reintroducing coarser boundaries on top of fine-grained services.
Shopify: deliberately a modular monolith
Shopify has written about keeping its core commerce application as a large Rails monolith, organised into components with enforced boundaries, rather than splitting it into services, while still running some separate services where justified.
How This Comes Up in Interviews
Architecture questions appear in system design rounds ("would you build this as microservices?"), in senior behavioural rounds ("tell me about an architecture decision you made"), and in backend rounds at companies running many services (see microservices interview questions).
Questions to prepare
- "Your startup has 8 engineers. The CTO wants microservices. What do you advise?"
- "How would you split this e-commerce monolith? Which service first?"
- "How do two services share customer data?"
- "How do you handle a transaction spanning orders and inventory services?"
- "What's a distributed monolith and how would you detect one?"
- "How would you migrate a 10-year-old monolith without downtime?"
Model answer for question 1
"I'd advise a modular monolith. With 8 engineers, the main risk is product iteration speed, and microservices would add CI/CD, observability and distributed-transaction overhead before we get any benefit from independent deploys. I'd enforce module boundaries with separate schemas per module and ArchUnit tests, so each module could become a service later. Exceptions: if we have a workload with very different scaling, like video transcoding, that would run as a separate worker from day one. We'd revisit when we have five or more teams stepping on each other's releases."
In a design round
When designing a large system, it is normal to draw several services. What interviewers check is whether each service has a reason to exist and owns its data, and whether cross-service flows are handled correctly (async events, sagas, idempotency). Drawing 15 boxes with arrows between all of them, each reading the same database, is a red flag. Drawing 5 services aligned to capabilities, saying which communicate synchronously and which via events, and naming the data each owns, is a strong answer. Messaging choices are covered in Kafka vs RabbitMQ vs SQS.
Red flags
- "Microservices are always more scalable." (A monolith scales horizontally behind a load balancer just fine.)
- Services per database table.
- Shared database across services, presented as normal.
- No mention of operational or organisational cost.
- Big-bang rewrite as the migration plan.
Frequently Asked Questions
Make Architecture Calls Like a Senior Engineer
Prepflix's system design course trains you to justify architecture decisions with trade-offs and numbers, the way senior interviewers expect.
Explore the Prepflix Course →