Start With the Monolith
Microservices became the default architecture for serious applications somewhere around 2015-2016. The pitch was compelling: independent deployment, independent scaling, teams that could ship without coordinating constantly. The actual record is more complicated.
Teams that split into microservices too early tend to go wrong in ways that are harder to reverse than staying in a monolith longer than needed. Both directions go wrong, but the early split goes wrong more often, and the repair is expensive. The default advice, absent strong specific reasons to do otherwise, is to start with the monolith, a view Sam Newman shared directly for greenfield projects: the risk of getting service boundaries wrong on a system you don’t yet understand is high enough that starting decomposed is rarely worth it.
What’s in It for Me?
The real advantages of microservices are narrower than the general pitch suggests.
Independent deployment means different parts of the system can release without coordinating a single large deployment. This matters when different parts change at very different rates, or when a bug in one area needs to be fixed immediately without releasing other in-progress work. But it requires that meaningfully different deployment cadences actually exist and not just that different teams write different code.
Independent scaling means services with different load profiles can be scaled separately. If payment processing is CPU-bound and content rendering is memory-bound, scaling them together wastes resources. But this requires meaningfully different load profiles in practice which most early-stage products don’t have.
Team isolation means two teams can work on separate services without code changes conflicting. This matters when the organization is large enough that a specific domain is clearly owned by a separate group with minimal cross-team dependencies. That’s not a team of five.
All three benefits require a specific scale of problem to justify the overhead. Teams of five have adopted microservices because it seemed like the scalable thing to do, before having the load profile, team boundaries, or deployment cadence that would justify the overhead.
Hidden Costs and Feature Creep
The costs are real and front-loaded. Teams pay them before seeing any of the benefits.
Network calls between services replace in-process function calls. They’re slower, they can fail, and they require retry logic, circuit breakers, and timeout handling that function calls don’t need. Every service-to-service interaction is now a distributed systems problem. A bug that would have been a simple stack trace in a monolith becomes a tracing exercise across multiple services.
Distributed tracing becomes necessary. When a request touches five services and something goes wrong, you need tooling to follow it across service boundaries. Setting up OpenTelemetry instrumentation, running Jaeger or similar, building dashboards, and deploying supporting services are all real work that someone has to maintain indefinitely.
Local development gets harder. Running the whole system locally requires spinning up multiple services, which usually means Docker Compose or Kind at minimum. When services depend on each other, getting a working local environment is an ongoing maintenance task, not a one-time setup.
Testing end-to-end behavior requires deploying or mocking multiple services. Integration tests are harder to write and slower to run. Contract testing between services adds another layer of tooling.
Every service needs its own CI/CD pipeline, health checks, alerting, and runbooks. Operations overhead grows roughly linearly with the number of services even if the product complexity hasn’t grown at the same rate.
The Tail Wagging the Dog
Conway’s Law: Any organization that designs a system (defined more broadly here than just information systems) will inevitably produce a design whose structure is a copy of the organization’s communication structure.
— Melvin E. Conway
Teams adopt microservices to enable organizational autonomy. But that causality is often backwards. Microservices work well when the team structure already has clear bounded contexts, for example, when different parts of the product are genuinely owned by separate teams that need to ship independently. Building microservices first, hoping organizational clarity follows, usually produces what’s sometimes called a distributed monolith: all the operational complexity of microservices with none of the team independence benefits, because the service boundaries don’t match the domain boundaries.
This pattern plays out more often than it gets discussed. The initial split starts clean and then it turns out that two services share a database, or that almost every deployment requires coordinated releases across three services, or that teams are coordinating just as tightly as before because features don’t respect service boundaries. The microservice split addressed a problem that didn’t exist in the form assumed.
The shared database case deserves emphasis: services that share a database aren’t really independent microservices. They’re a monolith with extra networking overhead. One of the core promises of microservices are that each service owns its data, a premise that is also one of the hardest parts to honor in practice. It requires duplicating data across service boundaries, dealing with eventual consistency, and giving up the referential integrity that a single relational database provides for free.
The Modular Monolith
There’s a useful middle ground that often gets skipped in the monolith vs microservices binary: the modular monolith.
A modular monolith is a single deployable unit with strict internal module boundaries that enforce domain separation. Instead of modules calling each other’s internals directly, they communicate through defined interfaces, the same way microservices would, but everything runs in the same process. No network latency between modules, no distributed tracing required. Local development is simple, testing is straightforward.
The modular monolith doesn’t solve the independent deployment problem since a bug in any module requires redeploying the whole thing. But it does address the coupling problem, and it does it without the operational overhead of distribution. If the team grows and a specific module needs to be extracted as a service, well-defined module boundaries make that extraction much cleaner than pulling apart a ball of mud.
This is arguably the architecture that teams should default to rather than jumping straight from “monolith” to “microservices”. The patterns for building modular monoliths are the same ones that will make future extraction tractable, such as explicit module interfaces, domain-driven package structure, and event-based communication between modules within the process.
What this looks like in practice: a billing module exports a single public interface, say BillingService, and keeps its database queries, domain models, and internal helpers package-private. The orders module doesn’t reach into billing’s internals; it calls BillingService.charge() and gets back a result. If billing needs to notify other modules that a charge succeeded, it publishes an internal domain event that other modules subscribe to without billing knowing who’s listening. The interface is already defined. The event contracts are already documented in code. If billing does need to become its own service eventually, the extraction starts from a much cleaner baseline than untangling a ball of mud.
Boundaries, Please: Domain-Driven Design
One of the harder problems in any distributed system is knowing where to draw the service boundaries. Domain-Driven Design (DDD) provides useful vocabulary here, particularly the concept of bounded contexts.
A bounded context is a region of the domain where a particular model applies consistently. Within a bounded context, terms have specific meanings. An “order” in the order management context means something different than “order” in the warehouse fulfillment context. When two parts of the system have different models of the same concept, that’s often a signal they belong in different services.
DDD’s approach is to find the natural seams in the domain model first, then let those seams guide the service boundaries. This is the opposite of the common approach, which draws service boundaries based on team structure or intuition and then discovers that the domain doesn’t respect those boundaries.
In practice, finding real bounded contexts requires living with the domain for a while. Teams that try to apply DDD to a system they don’t yet understand well tend to get the contexts wrong, and wrong service boundaries are expensive to fix especially when domains and bounded contexts don’t map 1 on 1. Another argument for starting with a monolith: you get to understand the domain before you’re committed to a decomposition.
Extracting Services: The Strangler Fig Pattern
When it does make sense to extract a service, the migration pattern matters. Big-bang rewrites that rip out a chunk of the monolith and replace it with a new service fail more often than they succeed. The new service has to replicate all the functionality of what it’s replacing, including the edge cases that only became apparent over years of production use.
The Strangler Fig pattern (named after the tree that grows around a host, eventually replacing it) is safer. New functionality goes into the new service from day one. Traffic that would have gone to the monolith is gradually routed to the service instead. The monolith shrinks as the service grows, until the relevant functionality is fully migrated.
As an approach that preserves optionality, if the extraction turns out to be more complex than expected it is possible to slow down or stop. The monolith still handles the traffic, nothing breaks. The big-bang approach doesn’t offer this: once you’ve started, you’re committed to finishing.
The signals that make an extraction worth it are specific.
- Meaningfully different deployment requirements: a component needs to deploy independently because it changes frequently and breakage there is high impact
- Scaling requirements are a bottleneck and scaling everything else to compensate is wasteful
- A specific domain area is clearly owned by a separate team with minimal dependencies on other teams’ work
These are concrete signals, not preferences. “We want to be able to scale this independently someday” isn’t one of them. “We’re currently over-provisioning everything to compensate for one CPU-bound service” is.
Work With the Constraints, Not Against Them
Working with constraints in mind drives architecture decisions and almost always boils down to team size and organizational structure, not technical capability or product requirements. A small team can move significantly faster in a well-structured monolith than in a distributed system.
The monolith’s main liability is accumulated coupling and shared state that makes it harder to change later. That’s a manageable liability with discipline around module boundaries that make that explicit. It’s a much more manageable problem than premature distribution.