Monolith to Microservices#
The decision to break a monolith into microservices is one of the most consequential architectural choices a team makes. Get it right and you unlock independent deployment, team autonomy, and targeted scaling. Get it wrong and you trade a manageable monolith for a distributed monolith – all the complexity of microservices with none of the benefits.
When to Stay with a Monolith#
Microservices are not an upgrade from monoliths. They are a different set of tradeoffs. A well-structured monolith is the right choice in many situations.
Stay with a monolith when:
- Your team is fewer than 15-20 engineers. The coordination overhead of microservices exceeds the benefit when a single team can own the entire codebase.
- Your deployment frequency is acceptable. If you deploy once a week and that works, microservices will not magically make you deploy faster. They often make it slower initially.
- Your scaling needs are uniform. If every part of your application scales the same way, there is no advantage to splitting it.
- You do not have operational maturity for distributed systems. Microservices require service discovery, distributed tracing, circuit breakers, and centralized logging. If you do not have these, you will spend months building infrastructure instead of features.
- Your domain is not well understood. Premature decomposition creates service boundaries in the wrong places. These are expensive to fix because they involve moving data and changing APIs.
Consider microservices when:
- Different parts of the system have different scaling requirements. An image processing pipeline that needs GPU instances should not force the user authentication service to scale the same way.
- Independent teams need to deploy independently. When Team A’s deployment is blocked by Team B’s broken tests, you have a coupling problem that microservices can solve.
- Different components have different technology requirements. A machine learning model in Python should not force the rest of your system into Python.
- Parts of the system have different availability requirements. A payment processing service may need 99.99% uptime while a recommendation engine can tolerate occasional downtime.
The Strangler Fig Pattern#
The strangler fig pattern is the safest way to decompose a monolith incrementally. Named after the fig tree that grows around a host tree and eventually replaces it, this pattern lets you extract services one at a time while the monolith continues to serve traffic.
How it works#
- Place a routing layer (API gateway or reverse proxy) in front of the monolith.
- Identify a bounded context to extract.
- Build the new service alongside the monolith.
- Route traffic for that context to the new service.
- Remove the dead code from the monolith.
- Repeat.
# Phase 1: All traffic goes to monolith
Client -> API Gateway -> Monolith
# Phase 2: Orders extracted, everything else still monolith
Client -> API Gateway -> /orders/* -> Order Service
-> /* -> Monolith
# Phase 3: Users extracted too
Client -> API Gateway -> /orders/* -> Order Service
-> /users/* -> User Service
-> /* -> MonolithRouting layer configuration#
An nginx-based routing layer for the strangler pattern:
upstream monolith {
server monolith.internal:8080;
}
upstream order_service {
server order-service.internal:8080;
}
server {
listen 80;
# Extracted service
location /api/v1/orders {
proxy_pass http://order_service;
proxy_set_header X-Request-ID $request_id;
}
# Everything else goes to monolith
location / {
proxy_pass http://monolith;
proxy_set_header X-Request-ID $request_id;
}
}The key discipline is extracting one service at a time and running it in production before starting the next extraction. Teams that try to extract three services simultaneously end up debugging distributed interactions between half-built services.
Domain-Driven Decomposition#
The hardest part of microservices is finding the right boundaries. Domain-driven design (DDD) provides the vocabulary and techniques for this.
Bounded contexts as service boundaries#
A bounded context is a boundary within which a particular domain model applies consistently. The “User” concept in billing (payment methods, invoices, subscription tier) is different from the “User” concept in social features (profile, followers, posts). These are different bounded contexts and strong candidates for separate services.
Finding bounded contexts:
- Map the nouns in your system. Group them by which ones are used together in the same workflows.
- Look for seams. Places where the monolith passes an ID rather than a full object are often natural boundaries.
- Examine team structure. Conway’s Law is real – your services will mirror your organizational communication patterns. Align service boundaries with team boundaries.
- Identify data ownership. Each piece of data should have exactly one authoritative source. If two parts of the system both write to the same table with different business rules, that table needs to be split.
Decomposition priorities#
Extract services in this order:
- Leaf services – components with no downstream dependencies in the monolith. A notification sender, a PDF generator, a report builder. These are low-risk extractions because failure does not cascade.
- High-churn components – the parts of the codebase that change most frequently. Extracting these gives the biggest deployment velocity improvement.
- Differently-scaled components – the parts that need different scaling profiles. An image processing pipeline can scale on GPU instances while the API layer scales on CPU.
- Core domain services – the business logic that defines your product. Extract these last because getting the boundaries wrong is most expensive here.
Database Splitting Strategies#
The database is the hardest part of decomposition. Two services sharing a database are not really separate services – they are a distributed monolith with a shared mutable state that will break in subtle ways.
Shared database (transitional)#
During migration, services may temporarily share a database. This is acceptable as a transitional state, not a permanent architecture.
# Transitional: both services read from shared DB
Order Service -> Shared DB <- Monolith
# Target: each service owns its data
Order Service -> Order DB
Monolith -> Monolith DBRules for the transitional phase: only one service writes to any given table. Other services read but never write. This prevents write conflicts while you build the data migration path.
Database-per-service#
The target state. Each service owns its schema, runs its own migrations, and exposes data through APIs only.
How to split:
- Identify which tables belong to which service based on write ownership.
- Create the new database with the relevant tables.
- Set up dual-writes: the service writes to both old and new databases during migration.
- Verify data consistency between old and new databases.
- Switch reads to the new database.
- Stop writes to the old database.
- Remove the old tables.
Change data capture#
For complex splits, use change data capture (CDC) to replicate data between databases during the transition. Debezium with Kafka Connect is the standard approach:
# Debezium connector configuration
{
"name": "orders-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "monolith-db",
"database.port": "5432",
"database.dbname": "monolith",
"table.include.list": "public.orders,public.order_items",
"topic.prefix": "monolith",
"plugin.name": "pgoutput"
}
}The new order service consumes these change events and populates its own database. Once caught up and verified, cut over.
Shared Libraries vs Service Calls#
When two services need the same logic, you have two options: share a library or make a service call. Each has costs.
Shared libraries are appropriate for:
- Utility code that changes rarely (date formatting, validation rules, serialization helpers)
- Data transfer objects (DTOs) that define API contracts
- Client SDKs that wrap service calls
Shared libraries become a problem when:
- They contain business logic that changes frequently. Every change requires updating the library version, rebuilding all consuming services, and deploying them. This is the “diamond dependency” problem.
- They create coupling between services. If two services share a library that contains domain models, those services are coupled through the library.
Service calls are appropriate for:
- Business logic that changes frequently and should be controlled by one team
- Logic that requires data from the owning service’s database
- Operations that need consistency guarantees (make one service the authority)
The heuristic: if the logic would need to change simultaneously across all consuming services, it belongs in a shared library. If it should be controlled and versioned by one team, it belongs in a service.
Communication Overhead#
Every service boundary introduces latency, failure modes, and operational complexity.
Latency budget. A monolith handles a request with in-process function calls measured in microseconds. A microservice call over the network takes milliseconds at minimum. A request that crosses 5 service boundaries adds 5x the network overhead plus serialization costs. Map your critical request paths and count the service hops. If a single user request requires more than 3-4 synchronous service calls, your boundaries may be wrong.
Failure multiplication. If each service has 99.9% availability, a request crossing 5 services has 99.5% availability (0.999^5). Circuit breakers and retries help, but they add complexity and latency. Design for failure: every service call should have a timeout, a retry policy, and a fallback behavior.
Data duplication. Without a shared database, services must maintain local copies of data they need from other services. The order service needs the customer’s shipping address, but the customer service owns that data. You either fetch it synchronously (adding latency and a failure point) or maintain a local copy (accepting eventual consistency). Most teams underestimate how much data duplication microservices require.
Decision Checklist#
Before decomposing, answer these questions honestly:
| Question | Monolith if… | Microservices if… |
|---|---|---|
| Team size | Under 15-20 engineers | Multiple independent teams |
| Deploy frequency needs | Current frequency is acceptable | Teams block each other on deploys |
| Scaling requirements | Uniform across the application | Components scale differently |
| Operational tooling | Basic logging and monitoring | Distributed tracing, service mesh, centralized logging |
| Domain understanding | Still evolving rapidly | Well-understood bounded contexts |
| Failure tolerance | Can afford simpler error handling | Need granular failure isolation |
If most answers point to monolith, invest in modularizing your monolith instead. A well-structured modular monolith with clear internal boundaries is far easier to maintain than a poorly-structured microservice architecture. You can always extract services later when the need is clear and the boundaries are proven.