Here is a scene that plays out more often than it should: a three-person startup decides, on day one, to build microservices. Six months later they have fifteen services, a Kafka cluster nobody fully understands, and a bug that requires tracing a request across eight services to reproduce. The team is exhausted. The product is half-finished. And somewhere in a Slack thread someone types the words we basically built a distributed monolith.
Microservices are genuinely powerful — at the right scale, with the right team, for the right reasons. But they are a tax you pay for organisational scale, not a starting point. This guide walks honestly through all three architectural shapes — monolith, modular monolith, and microservices — and gives you the signals to know when (and whether) to move between them.
The monolith: one deployable to rule them all
A monolith is a single deployable unit. One build, one binary (or one set of compiled assets), one deployment step. Every feature, every module, every database query lives in the same process.
That sounds limiting, and the word itself has acquired a faintly embarrassing ring — as if admitting you have a monolith is admitting you have not caught up with modern engineering. That framing is wrong, and it has caused a lot of unnecessary pain.
A monolith is the right default. Here is why:
- Local calls are free. A function call within a process is nanoseconds. A network call between services is milliseconds — and it can fail, time out, return a partial result, or get lost entirely. You do not carry that complexity until you need to.
- Transactions are easy. A relational database gives you ACID transactions across your whole data model. In a monolith, moving money from account A to account B is one transaction. In microservices, it becomes a distributed transaction or a saga — genuinely hard problems with real failure modes.
- Debugging is straightforward. One process, one log stream, one stack trace. You find the problem. In a distributed system, a single user action can spawn dozens of asynchronous operations across multiple services, and correlating them requires purpose-built observability tooling.
- Deployment is simple. One artefact, one pipeline, one rollback strategy. You can ship confidently on day one.
- Big companies ship big monoliths. Shopify ran a Rails monolith for years at enormous scale. Stack Overflow serves millions of requests per day from a handful of servers running a monolith. A monolith does not prevent you from scaling — it just scales differently.
The real pains of a monolith are real, but they arrive later than people expect. Build times grow as the codebase grows — a full rebuild that takes two minutes at 50k lines takes twelve minutes at 500k. Merge friction increases when twenty engineers all modify the same codebase; long-lived branches and conflicts become a daily ritual. Deployment coupling means every team deploys together — a risky change by one team can hold up everyone else’s release.
These pains are genuine. But they are the pains of success, and they arrive much later than hype suggests. Most teams should be so lucky as to hit them.
The modular monolith: the sweet spot most teams skip
A modular monolith is still one deployable unit — but internally it is organised into modules with enforced boundaries. Modules communicate through clear interfaces. One module is not allowed to reach directly into another module’s internals: no importing private classes from a sibling, no sharing database tables, no calling internal helper functions across module lines.
This sounds like a small discipline, but it changes the game significantly.
Think of it like the floorplan of a house. A monolith without modules is a studio apartment: one big open room where everything is everywhere. A modular monolith gives you rooms with walls — you still live in one house, you still share the plumbing and the front door, but the kitchen and the bedroom have their own distinct purposes and you do not cook on the bed.
The modules map naturally to bounded contexts from Domain-Driven Design: a Billing module, an Inventory module, a Notifications module. Each owns its own domain logic and its own slice of the database schema. They expose a public interface (a service facade or a set of public functions) and keep everything else private.
The practical gains are substantial:
- Independent reasoning. A developer working on
Billingcan understand and modify it without holding the entire codebase in their head. - Safe refactoring. You can rewrite a module’s internals as long as the public interface is preserved — the rest of the app does not notice.
- Clear ownership. Teams claim modules. Code review stays focused. Onboarding a new developer means handing them one module, not a 200k-line codebase.
- Migration-ready seams. If you ever do need to extract a service, the boundary is already drawn. The module becomes a service. The interface becomes an API. The work is significant, but it is known work, not exploratory surgery.
The modular monolith is the architecture most teams skip straight over, rushing from a tangled monolith to microservices without stopping here. That is a mistake. For the majority of product teams — under 100 engineers, single-digit services, one or two deployment pipelines — a well-maintained modular monolith is the most productive place to live.
Microservices: what you actually buy
Microservices decompose the system into independently deployable services. Each service owns its own process, its own deployment pipeline, and — crucially — its own data store. Services talk to each other over a network: HTTP, gRPC, message queues, event streams.
When microservices work well, they deliver four things that nothing else can match at scale:
- Independent deployment. The payments team can ship a change to the payments service on Tuesday afternoon without coordinating with the recommendations team, the search team, or the notifications team. At a company with hundreds of engineers, this is transformational — it is the difference between deploying once a week and deploying a hundred times a day.
- Independent scaling. If your
ImageProcessingservice needs forty CPU cores during peak hours and yourUserProfileservice needs two, you can scale them separately. In a monolith, you scale the whole thing — you buy forty cores for every feature whether it needs them or not. - Team autonomy. Each service is a small, self-contained product. A team of five engineers can own a service completely: choose its language, its database, its deployment cadence. This is the organisational superpower of microservices — Conway’s Law working for you instead of against you.
- Fault isolation. A memory leak in the
RecommendationEnginebrings down theRecommendationEngine, not checkout. You can degrade gracefully — the store still works, products just do not have personalised suggestions. In a monolith, an uncaught exception in any module can take down the whole process.
Notice the pattern: everything in that list is about organisational and operational scale. These are not concerns that apply to a team of eight people building a product that has not found product-market fit yet.
What you actually pay: the distributed-systems tax
Microservices do not come free. Every benefit above has a corresponding cost, and the costs are non-trivial. Underestimating them is how teams end up exhausted and under-delivered.
The worst outcome is not microservices or a monolith — it is a distributed monolith: microservices that are so tightly coupled that they must be deployed together, share the same database, or fail together. You pay every cost of microservices and receive none of the benefits. It happens when teams split by technical layer instead of business domain, or when services call each other synchronously in long chains. If ServiceA can only deploy after ServiceB, you have built a distributed monolith.
Here is the tax in full:
- Network calls fail. A function call cannot fail (barring a bug). A network call can fail, time out, return a stale cached value, or succeed on retry after the first attempt already had side effects. You must handle partial failure everywhere, constantly.
- Eventual consistency. Services own their own data. Keeping that data consistent across service boundaries requires careful design — event-driven patterns, sagas, idempotent operations, compensating transactions. Explaining to a product manager why a user can place an order but not immediately see it in their order history is awkward.
- Distributed transactions are hard. The two-phase commit protocol exists and is miserable. Most teams use the Saga pattern instead, which shifts the complexity from the database to your application code. Both are significantly more complex than
BEGIN; UPDATE; COMMIT;. - Debugging is a different skill. A stack trace no longer tells you what went wrong. You need distributed tracing (Jaeger, Zipkin, Honeycomb), correlation IDs in every log line, service meshes, and dashboards that correlate events across services. Building and maintaining this observability infrastructure is real engineering work.
- Ops overhead multiplies. One service needs a Dockerfile, a Kubernetes deployment, an ingress rule, a health check endpoint, CPU and memory limits, a log aggregation config, and alert rules. Multiply by thirty services. This overhead scales linearly with the number of services; your engineering headcount does not.
- Testing is harder. Unit tests are still easy. But integration tests that span service boundaries require running multiple services simultaneously, managing their versions and configurations, and dealing with test data across databases. Contract testing (Pact, etc.) helps but adds its own discipline to learn.
None of these costs are unsolvable — the industry has mature tooling for all of them. The point is that they are real costs that must be paid every day, and they are not worth paying until the benefits they unlock are actually needed.
Signals: when to split (and when not to)
So how do you know when you have crossed the threshold where microservices are worth their cost? Here are the signals that genuinely indicate it is time:
- Independent scaling needs are real and expensive. You can point to a specific component that needs 10x the resources of the rest of the system, and you are paying for those resources across the whole monolith. Splitting would save meaningful money or meaningfully improve performance.
- Deploy contention is chronic. Teams are regularly blocked from shipping by other teams’ in-flight changes. The coordination overhead of shared deployment is measurably slowing you down — not occasionally, but as a weekly or daily frustration.
- Team count demands it. You have multiple teams of five or more engineers working on distinct business domains with different deployment cadences, different technology preferences, and genuinely independent roadmaps. Conway’s Law predicts the architecture you will end up with — you might as well plan for it.
- A component has fundamentally different reliability or security requirements. Payments, authentication, and PII storage often warrant isolation not for performance reasons but for compliance, blast-radius reduction, and the ability to audit them independently.
- A module is already effectively separate. It has no shared state with the rest of the system, communicates only through well-defined events or APIs, and a different team owns it. The organisational seam already exists — making it a service boundary is formalising what is already true.
And here are the signals that you are splitting too early:
- Your entire engineering org is under twenty people.
- You are splitting because it feels more scalable rather than because you have hit a concrete limit.
- The services you are planning would need to be deployed together to work.
- You cannot yet draw a clean boundary where each service owns its own data with no shared tables.
- Your team does not yet have the observability infrastructure (distributed tracing, centralised logging, alerting) to debug a distributed system.
- The main reason is that microservices are on the job description or in the company’s marketing copy.
How to split well: seams, strangler figs, and data ownership
If the signals above point toward splitting, the how matters enormously. Splitting badly produces the distributed monolith described above. Splitting well produces genuinely independent services that pay for themselves.
Split along bounded contexts, not technical layers. Do not create a DatabaseService or a ValidationService — those are technical concerns, not business ones. Create an OrderService, a BillingService, an InventoryService — units of business capability with clear, stable meanings in your domain. A bounded context is a portion of the domain where a specific model applies and the language is consistent. That is where service boundaries belong.
Use the strangler-fig pattern. Named after a vine that gradually envelops a host tree, the strangler-fig pattern lets you extract services incrementally rather than all at once. You stand up the new service alongside the monolith, route a specific slice of traffic to it, verify it works, then remove the corresponding code from the monolith. The monolith shrinks over time; it is never rewritten. This is safer, more reversible, and much less likely to result in a six-month big-bang migration that never quite finishes.
Extract one service at a time. Every extraction teaches you something. The second extraction will go better than the first. Trying to extract five services simultaneously distributes the learning and multiplies the risk.
Data ownership is non-negotiable. A service that shares a database table with another service is not a service — it is a module with extra network overhead. Each service must own its own data. If two services need the same data, one of them is authoritative and the other fetches it via API or synchronises it via events. This constraint is painful to establish and painful to maintain, but it is what gives you the independent deployability and fault isolation you came for.
Use ports to define the seams before you extract. If you have followed a Ports & Adapters approach inside your modular monolith, the extraction becomes almost mechanical: the port defines the API, the adapter behind it becomes the service client. The domain logic does not change — only the delivery mechanism. This is one of the strongest practical arguments for building with ports in the first place: they give you extraction-ready seams for free.
Side-by-side comparison
| Dimension | Monolith | Modular Monolith | Microservices |
|---|---|---|---|
| Deploy unit | One process, one pipeline | One process, one pipeline | Many processes, many independent pipelines |
| Data ownership | Shared database; all code can touch all tables | Shared database; modules own their schema areas by convention | Each service owns its own database; no shared tables |
| Failure mode | One process crash takes down the whole app | One process crash takes down the whole app | Service failures are isolated; others degrade gracefully |
| Team fit | Best for 1–3 teams; friction grows with team count | Best for 2–8 teams; modules align to team ownership | Necessary for 5+ teams shipping at independent cadences |
| Operational cost | Low: one deployment, one log stream, one set of alerts | Low: same ops footprint as a plain monolith | High: observability, container orchestration, service mesh, per-service CI/CD |
| Transaction model | Full ACID transactions trivially | Full ACID transactions trivially | Sagas or eventual consistency; distributed transactions are hard |
| When to use | New products, small teams, unknown domain | Growing product with clear domain boundaries, 10–100 engineers | Multiple autonomous teams, proven domain model, real independent scaling needs |
The honest view by company size
Architecture advice tends to come from companies that have already crossed the threshold where microservices make sense — because those are the companies with engineering blogs, conference talks, and the resources to write detailed post-mortems. That creates survivorship bias. Here is a more honest map:
Startup (1–15 engineers). Build a monolith. You do not know which parts of your system will need to scale, which features will survive, or which domain boundaries are real yet. Premature decomposition locks in decisions before you have enough information to make them. Shopify, GitHub, and Basecamp all started as Rails monoliths. So did Twitter, and they ran it for years at significant scale before decomposing under genuine load.
Scale-up (15–80 engineers, growing). This is where the modular monolith earns its keep. You have enough engineers that uncontrolled growth in a single codebase causes real friction, but you still deploy as a team and the overhead of full microservices would kill your velocity. Invest in module boundaries, team ownership, and clean interfaces. Keep the option to extract services, but do not exercise it yet unless a concrete scaling need forces your hand.
Enterprise / large org (80+ engineers, multiple autonomous teams). Selective microservices make sense here — but selective is the key word. The most effective large-scale architectures are neither pure monoliths nor a sea of identical-sized services: they are a thoughtful mix. A handful of core services for the most load-bearing, domain-critical capabilities, a modular monolith for the operational middle, and a few specialised services where independent scaling or compliance genuinely demands it. Amazon did not decompose everything at once; they identified the seams under load and extracted them one by one.
When Netflix and Amazon describe their microservices journeys, the part that gets quoted is the architecture diagram with hundreds of services. The part that gets left out is that they started as monoliths, ran them until the pain was undeniable, and then invested years and enormous engineering effort in the transition — including building much of the tooling that now makes microservices viable. They are telling you where they arrived, not where you should start.
Key takeaways
- Monolith is the right default. Simple to build, easy to debug, trivially transactional. The pains arrive later than hype suggests and only at genuine scale.
- Modular monolith is the most underused option. Enforced boundaries between internal modules give you team clarity, safe refactoring, and extraction-ready seams — without any distributed-systems complexity.
- Microservices are a tax, not a feature. You pay in network failures, eventual consistency, distributed tracing, and ops overhead. The payoff — independent deployment, independent scaling, team autonomy — is real but only at the scale that demands it.
- The distributed monolith is the worst outcome. Tightly coupled services that must be deployed together give you all the costs of microservices and none of the benefits. Split along bounded contexts, not technical layers.
- Data ownership is the hard constraint. A service that shares a database with another service is not a service. Each service must own its own data; consistency across boundaries requires explicit design.
- Use the strangler-fig pattern to split incrementally. Extract one bounded context at a time. Each extraction teaches you something and is reversible. Never do a big-bang decomposition.
- Match architecture to team size, not aspiration. Startup: monolith. Scale-up: modular monolith. Large autonomous teams: selective microservices where genuinely needed.
Architecture decisions ripple forward in time and compound. The next question many teams face after deciding to split is what to do about the frontend — whether to serve a single unified UI or decompose that too. That rabbit hole is explored in Micro-Frontends: When, Why, and What They Actually Cost.