Migrating a monolithic system that is live in production toward a microservices architecture is one of the riskiest maneuvers a software team can undertake. The classic mistake is the complete rewrite ("big bang rewrite"): freezing new-feature development, building the new system from scratch over months, and, on the day of truth, discovering that it fails to replicate all the nuances of the original. The industry has learned to do it another way: incrementally, keeping the monolith alive and in production while pieces are extracted from it one by one. The pattern that gives this strategy its name is the Strangler Fig, coined by Martin Fowler. In this lesson we will look at the incremental strategy, how to identify the monolith's seams, how to protect new models with an Anti-Corruption Layer, the Branch by Abstraction technique, and the risks you should anticipate.
Contents
- The problem with the "big bang" rewrite
- The Strangler Fig pattern
- Identifying the monolith's seams
- Branch by Abstraction
- Anti-Corruption Layer (ACL)
- Step-by-step incremental strategy
- Risks and how to mitigate them
- Common Mistakes and Tips
- Exercises
- Conclusion
- The problem with the "big bang" rewrite
The temptation to throw away the monolith and start from scratch is enormous: the old code "is disgusting," nobody fully understands how it works, and the new paradigm promises cleanliness. But the total rewrite almost always fails for the same reasons:
- The monolith encodes years of implicit business rules. Many of those rules are undocumented: they live in seemingly absurd
ifstatements that actually handle a real edge case. - The business does not stop. While you rewrite for 18 months, the monolith keeps receiving changes. You are chasing a moving target.
- Cutover day is a leap into the void. All the data and traffic migration happens at once, with maximum risk and minimum reversibility.
The mature alternative is to strangle the monolith bit by bit. Each step is small, is in production, and is reversible.
- The Strangler Fig pattern
The name comes from the strangler fig: a plant that grows around a host tree, gradually envelops it, and over the years replaces it completely until the original tree disappears. Applied to software, the idea is to interpose a routing layer (usually a gateway or proxy) in front of the monolith. That layer decides, request by request, whether it is served by the old monolith or by an already-extracted new service.
graph TD
Cliente[Client] --> GW[Gateway / Strangler Facade]
GW -->|migrated functionality| MS1[New Payments service]
GW -->|migrated functionality| MS2[New Catalog service]
GW -->|not yet migrated| Mono[Legacy monolith]
MS1 --> DBN[(Service DB)]
Mono --> DBM[(Monolith DB)]The diagram shows the key piece: the Strangler Facade (the facade or gateway). At first, almost all traffic goes to the monolith. As you extract capabilities, you redirect specific routes (for example, /payments/*) to the new service. The client never notices the change because it always talks to the same facade. When the last route leaves the monolith, it is left empty and can be shut down.
Advantages over the big bang:
- Continuous delivery of value: each extraction is an increment that already delivers.
- Bounded risk: if a new service fails, redirect that route back to the monolith.
- No freeze: the monolith keeps evolving in the parts not yet migrated.
- Identifying the monolith's seams
A seam (a term from Michael Feathers) is a point in the system where you can alter behavior without editing the code at that location. To extract a microservice you need to find clean seams that delimit a business capability. The best candidates to leave first are modules that meet:
| Criterion | Why it makes extraction easier |
|---|---|
| Low coupling with the rest | Few dependencies to break when separating it |
| High internal cohesion | Forms a complete business capability on its own |
| Relatively independent data | Its database is not tangled up with every table |
| High value or high change | Justifies the effort: either it scales differently, or it changes a lot |
| Manageable risk | If it fails, it does not bring down the whole business |
The worst candidate to start with is the central module that touches everything (for example, "Customer" in many companies). Start with the petals, not the heart.
// BEFORE: the monolith calls its internal class directly
public class OrderProcessor {
private final ShippingCalculator calculator = new ShippingCalculator(); // rigid dependency
public Order process(Order o) {
double cost = calculator.calculate(o.getDestination(), o.getWeight());
o.setShippingCost(cost);
return o;
}
}Here there is no seam: OrderProcessor directly instantiates ShippingCalculator with new. To be able to replace the shipping calculation with a remote service, we first have to create a seam. That leads us to the next technique.
- Branch by Abstraction
Branch by Abstraction is a technique for making a large change in small steps without using long-lived branches in version control. The idea: you introduce an abstraction (an interface) between the consumer and the current implementation; then you add a second implementation behind the same abstraction; finally you switch from one to the other and delete the old one.
// STEP 1: extract an abstraction (interface) over the capability
public interface ShippingService {
double calculate(Address destination, double weight);
}
// STEP 2: the current (legacy) implementation satisfies the interface, without changing its logic
public class LocalShippingService implements ShippingService {
private final ShippingCalculator calculator = new ShippingCalculator();
public double calculate(Address destination, double weight) {
return calculator.calculate(destination, weight);
}
}
// STEP 3: the consumer depends on the abstraction, not on the concrete class
public class OrderProcessor {
private final ShippingService shipping; // injected
public OrderProcessor(ShippingService shipping) { this.shipping = shipping; }
public Order process(Order o) {
o.setShippingCost(shipping.calculate(o.getDestination(), o.getWeight()));
return o;
}
}Let's go step by step through why this enables the migration:
- Step 1 creates the "branch by abstraction":
ShippingServiceis now the switch point. All the difference between the old and the new world will pass through this interface. - Step 2 wraps the existing logic without touching it. The system still behaves the same; we have only interposed a thin layer.
- Step 3 inverts the dependency:
OrderProcessorno longer knows how shipping is calculated, only that aShippingServiceexists.
Now we can add a new implementation that calls the microservice over the network:
// STEP 4: new implementation that delegates to the remote microservice
public class RemoteShippingService implements ShippingService {
private final HttpClient client;
public RemoteShippingService(HttpClient client) { this.client = client; }
public double calculate(Address destination, double weight) {
// HTTP call to the new shipping service
return client.post("/shipping/calculate",
Map.of("destination", destination, "weight", weight), Double.class);
}
}With a feature flag you decide which implementation is injected. You can switch it for 1% of the traffic, observe, and gradually ramp up. If something goes wrong, you fall back to LocalShippingService instantly. When 100% of the traffic uses the remote version for a reasonable period, you delete the local implementation. The "branch" has been merged without drama.
- Anti-Corruption Layer (ACL)
The legacy monolith usually has an old data model, with confusing names, fields reused for several purposes, and concepts that no longer fit the current business. If the new microservice adopts that model as-is, it inherits the corruption. The Anti-Corruption Layer (a term from Eric Evans in DDD) is a translation layer that isolates the clean model of the new service from the dirty model of the legacy.
graph LR
MSnuevo[New service\nclean model] --> ACL[Anti-Corruption Layer\ntranslation]
ACL --> Legado[Monolith / legacy system\nold model]// Clean model of the new service
public record Customer(String id, String fullName, LoyaltyLevel level) {}
// The ACL translates from the legacy format to the clean model
public class CustomerAcl {
private final LegacyCustomerApi legacy;
public CustomerAcl(LegacyCustomerApi legacy) { this.legacy = legacy; }
public Customer get(String id) {
// The legacy returns a map with cryptic names: CL_NOM, CL_AP1, CL_TIP
Map<String,String> raw = legacy.fetch(id);
String name = raw.get("CL_NOM") + " " + raw.get("CL_AP1");
// "TIP=3" in the legacy means VIP customer: we translate to our enum
LoyaltyLevel level = "3".equals(raw.get("CL_TIP"))
? LoyaltyLevel.VIP : LoyaltyLevel.STANDARD;
return new Customer(id, name.trim(), level);
}
}What we achieve with this ACL:
- The new service never sees
CL_NOMorCL_TIP. It works withCustomer,fullName, and a clearLoyaltyLevelenum. - All the legacy's "ugliness" is encapsulated in a single place. If the legacy changes, you only adjust the ACL.
- It is a natural deletion point: when the legacy dies, you remove the ACL and the clean model survives intact.
The ACL has a cost (code and translation latency), but it protects the investment in the new model. It is almost mandatory when integrating with legacy systems you do not control.
- Step-by-step incremental strategy
A typical, safe sequence to strangle a monolith:
- Put a facade in front of the monolith (gateway/proxy). For now it forwards 100% to the monolith. Nothing changes functionally, but it creates the switch point.
- Add observability: logs, metrics, and distributed traces. You cannot migrate blindly; you need to measure latency and errors before and after.
- Choose the first capability according to the seam criteria (low coupling, separable data).
- Apply Branch by Abstraction inside the monolith for that capability.
- Build the microservice with its own database. If it touches legacy data, separate the data (replication or temporary dual writes).
- Put an ACL in place if the legacy model contaminates.
- Switch traffic gradually with feature flags; observe; roll back if needed.
- Delete the dead code in the monolith once it is stabilized.
- Repeat with the next capability until the monolith is empty.
Data separation is usually the hardest part. Common techniques: read-only views over the monolith's DB at first, then dual writes, and finally the service's own DB as the source of truth.
- Risks and how to mitigate them
| Risk | Mitigation |
|---|---|
| Transactions that crossed modules are no longer ACID | Saga pattern, eventual consistency, and designing compensations |
| Latency: what was an in-memory call is now network | Measure, batch calls, cache; do not extract if chatter is extremely high |
| Shared database that prevents separation | Identify tables by domain, views, progressive dual writes |
| The "zombie" monolith that never finishes dying | Define a progress metric (migrated routes) and a shutdown date |
| Explosion of operational complexity | Invest beforehand in CI/CD, observability, and platform |
| Poorly chosen boundaries | Validate with DDD (Bounded Contexts) before cutting |
The most underestimated risk is data consistency: inside the monolith a local transaction solved everything; in a distributed setting you need sagas and must accept eventual consistency. Do not extract a service if its capability participates in transactions tightly coupled with others, unless you are prepared to handle the compensation.
- Common Mistakes and Tips
- Starting with the central module. It is the most entangled. Start with peripheral ones to gain experience and confidence.
- Migrating without observability. Without metrics and traces you will not know whether the new service is better or worse. Instrument before cutting.
- Sharing the database "temporarily" forever. A database shared between the monolith and the new service is a distributed microservice with the worst of both worlds. Have a real separation plan.
- Not using feature flags. Without gradual switching or rollback, every extraction becomes a mini big bang again.
- Forgetting to delete the old code. If you do not remove the legacy implementation after switching, you accumulate debt and confusion.
- Tip: treat each extraction as an experiment with a hypothesis ("this service will lower payment latency by 30%"). If it does not hold, consider reverting.
- Exercises
Exercise 1. You have an e-commerce monolith with the modules: Catalog, Cart, Payments, Customer, and Recommendations. Which would you extract first and which would you leave until last? Justify it using the seam criteria.
Exercise 2. The "Notifications" module directly instantiates new EmailSender() in 12 places. Describe how you would apply Branch by Abstraction to be able to replace it with a notifications microservice, step by step.
Exercise 3. The new "Billing" service must read customers from the legacy, whose table uses the column EST with values A, B, C meaning active, temporary suspension, and permanent suspension. Explain which piece you would use and why, and sketch the translation.
Solutions
Solution 1. First Recommendations: low coupling (it is an add-on), relatively independent data, and if it fails it does not break the purchase. Then Catalog or Payments. Customer last: it is the central module, almost everything depends on it, and separating its data is the riskiest part.
Solution 2. (1) Create the NotificationService interface. (2) Implement it with LocalNotifications that wraps the current EmailSender without changing its logic. (3) Replace the 12 instantiations with injection of NotificationService. (4) Create RemoteNotifications that calls the microservice over the network. (5) Switch gradually with a feature flag. (6) Delete LocalNotifications and EmailSender when 100% uses the remote version stably.
Solution 3. An Anti-Corruption Layer, so that the Billing service does not inherit the cryptic column EST. The ACL translates: A → CustomerStatus.ACTIVE, B → CustomerStatus.TEMPORARY_SUSPENSION, C → CustomerStatus.PERMANENT_SUSPENSION, exposing a clear enum to the clean model. If tomorrow the legacy changes the codes, only the ACL is touched.
- Conclusion
Migrating from monolith to microservices is not a leap, but a trickle. The Strangler Fig pattern keeps the system alive and in production while capabilities are extracted from it one by one through a routing facade. We have seen how to identify clean seams, how to create them with Branch by Abstraction and feature flags, how to protect the new model with an Anti-Corruption Layer, and what the risks are—especially those of data and consistency—that must be anticipated. Any incremental change of this magnitude generates many decisions that are worth recording and justifying so that the team neither forgets nor repeats them. That is precisely what the next lesson is about: Architectural Governance and Architecture Decision Records (ADR), where we will learn to document and govern architecture decisions in a lightweight and sustainable way.
Application Architecture Course
Module 1: Fundamentals of Application Architecture
- What Is Application Architecture?
- The Role of the Software Architect
- Quality Attributes and Non-Functional Requirements
- Architectural Decisions and Trade-offs
- Architecture Documentation: Views and the C4 Model
Module 2: Design Principles and Tactics
- Coupling, Cohesion and Separation of Concerns
- SOLID Principles Applied to Architecture
- DRY, KISS, YAGNI and Other Design Principles
- Architectural Tactics for Quality Attributes
- Managing Technical Debt
Module 3: Architectural Styles and Patterns
- Monolithic Architecture
- Layered Architecture (N-Tier)
- Client-Server Architecture
- Hexagonal Architecture (Ports and Adapters)
- Clean and Onion Architecture
Module 4: Distributed Architectures and Microservices
- Introduction to Distributed Systems
- Microservices Architecture
- Service Decomposition and Bounded Contexts
- API Gateway, Service Discovery and Inter-Service Communication
- Resilience Patterns: Circuit Breaker, Retry and Bulkhead
- The CAP Theorem and Data Consistency
Module 5: Event-Driven Architectures and Messaging
- Fundamentals of Event-Driven Architecture
- Asynchronous Messaging: Queues and Brokers
- Event Patterns: Event Sourcing and CQRS
- Managing Distributed Transactions: The Saga Pattern
- Real-Time Data Streaming
Module 6: Domain-Driven Design (DDD)
- Core DDD Concepts
- Strategic Design: Bounded Contexts and Ubiquitous Language
- Tactical Design: Entities, Aggregates and Repositories
- Context Mapping
Module 7: Data and Persistence
- Persistence Strategies: SQL vs NoSQL
- Data Access Patterns: Repository, Unit of Work and DAO
- Database per Service and Distributed Data Management
- Caching and Invalidation Strategies
Module 8: Cloud Architecture and Deployment
- Cloud Computing Fundamentals (IaaS, PaaS, SaaS)
- Containers and Orchestration with Docker and Kubernetes
- Serverless Architecture
- Cloud-Native Design Patterns
- Infrastructure as Code (IaC)
Module 9: Quality, Security and Observability
- Scalability: Horizontal vs Vertical and Load Balancing
- High Availability and Fault Tolerance
- Security by Design and Authentication/Authorization
- Observability: Logging, Metrics and Tracing
- Performance and Load Testing
