Why migration strategies matter
Every successful system eventually needs to change its architecture. A monolith that worked for a startup becomes a bottleneck as the team grows. A legacy framework reaches end-of-life. A database that served millions of requests starts to buckle. The question is not *if* you will migrate - it is *how*. Two dominant strategies exist for migrating software systems:
- <strong>Big-Bang migration</strong>: switch everything at once
- <strong>Strangler Fig migration</strong>: gradually replace pieces until nothing of the original remains
Choosing the wrong one can delay your project by months, introduce production incidents, or - in extreme cases - kill the product. This guide gives you a framework to make that choice, with concrete Java code examples for each approach.
Why the name "Strangler Fig"?
The pattern is named after the <strong>strangler fig</strong> plant. A strangler fig seed germinates in the canopy of a host tree. It sends roots down to the ground, slowly wrapping around the host. Over years, the fig grows thicker while the host tree decays. Eventually, the host is completely replaced - but the shape and structure remain. In software terms: you build a new system incrementally around the old one, route traffic to the new parts, and eventually decommission the old system when nothing is left. ---
What is Big-Bang Migration?
The concept
Big-Bang (also called "cut-over" or "flash-cut") migration means building the new system in parallel, then switching all traffic from old to new in a single event.
┌─────────────────────────────────────────┐
│ BEFORE THE CUT-OVER │
│ │
│ Users ──► Old Monolith (production) │
│ │
│ New System ──► Building in isolation │
└─────────────────────────────────────────┘
│
│ ONE SWITCH
▼
┌─────────────────────────────────────────┐
│ AFTER THE CUT-OVER │
│ │
│ Users ──► New System (production) │
│ │
│ Old Monolith ──► Decommissioned │
└─────────────────────────────────────────┘Typical timeline
Months 1-6: Build new system in parallel Month 7: Data migration, dry runs, testing Month 7 (one weekend): The cut-over Month 8+: Stabilization and decommission old system
Risks
- <strong>All-or-nothing failure</strong>: if the new system has issues, there is no fallback without a reverse cut-over, which is expensive and risky.
- <strong>Data synchronization</strong>: between the cut-over window, data written to the old system must be migrated to the new one. Any mismatch causes data loss or corruption.
- <strong>Testing impossibility</strong>: you cannot fully test a system at production scale before switching. Edge cases always emerge after cut-over.
- <strong>Organizational pressure</strong>: the cut-over date creates deadline stress. Teams rush, cut corners, and ship with known issues because "we cannot delay the switch."
- <strong>Long feedback loop</strong>: since the new system is built in isolation for months, developers get no feedback from production until after cut-over. If the architecture is wrong, it is discovered too late.
When Big-Bang actually makes sense
Big-Bang is not always wrong. It works well when:
- <strong>The system is small</strong> (a few thousand lines, one team)
- <strong>Downtime is acceptable</strong> (internal tools, batch systems)
- <strong>The new system is fundamentally different</strong> (different language, different database paradigm)
- <strong>Rollback is trivial</strong> (you can flip back a DNS record)
- <strong>The old system cannot run alongside the new one</strong> (hardware decommissioning, license expiry)
---
What is Strangler Fig Migration?
The concept
Strangler Fig migration replaces a system incrementally. You route specific traffic to new components while the old system continues handling the rest. Over time, the new system grows and the old system shrinks until nothing remains.
PHASE 1: First Feature Strangled
Users ──► Router ──► ┌──────────────────┐
│ New Service │ (new feature)
│ (e.g., Search) │
└──────────────────┘
│
▼ (falls through)
┌──────────────────┐
│ Old Monolith │ (everything else)
└──────────────────┘
PHASE 2: Multiple Features Strangled
Users ──► Router ──► ┌──────────────────┐
│ Search Service │
├──────────────────┤
│ Auth Service │
├──────────────────┤
│ Payment Service │
└──────────────────┘
│
▼ (falls through)
┌──────────────────┐
│ Old Monolith │ (remaining features)
└──────────────────┘
PHASE 3: Old System Decommissioned
Users ──► Router ──► ┌──────────────────┐
│ Search Service │
├──────────────────┤
│ Auth Service │
├──────────────────┤
│ Payment Service │
├──────────────────┤
│ Order Service │
├──────────────────┤
│ ... │
└──────────────────┘
Old Monolith ──► Decommissioned ✓How it works step-by-step
- <strong>Identify a bounded context</strong> within the old system (e.g., search, authentication, payments).
- <strong>Build a new service</strong> that implements that feature independently.
- <strong>Route traffic</strong> for that feature to the new service (using a proxy, gateway, or feature flag).
- <strong>Verify correctness</strong> in production with real traffic but limited blast radius.
- <strong>Migrate data</strong> if needed (dual-write or backfill).
- <strong>Remove the old code</strong> once the new service is stable.
- Repeat until nothing is left of the old system.
Advantages
- <strong>Risk is incremental</strong>: each migration is small, reversible, and independently testable.
- <strong>Production feedback early</strong>: you get real traffic on the new system from day one.
- <strong>Rollback is easy</strong>: if the new service fails, route traffic back to the old code.
- <strong>Team can learn gradually</strong>: no need to understand the entire new system before shipping.
- <strong>Business continues</strong>: no big freeze or feature stop during migration.
Disadvantages
- <strong>Coordination complexity</strong>: old and new systems coexist, often reading/writing the same data.
- <strong>Dual maintenance</strong>: you maintain two versions of some features during the transition.
- <strong>Router/API gateway dependency</strong>: you need infrastructure to split traffic.
- <strong>Data consistency across boundaries</strong>: the new service and old monolith may need to share data, leading to eventual consistency challenges.
- <strong>Takes longer</strong>: the overall migration spans more calendar time.
---
Detailed comparison
| Dimension | Big-Bang | Strangler Fig |
|---|---|---|
| <strong>Risk profile</strong> | High - all eggs in one basket | Low - each change is small and reversible |
| <strong>Time to completion</strong> | Shorter calendar time (months) | Longer (months to years) |
| <strong>Team productivity during migration</strong> | Feature development freezes | Continues normally |
| <strong>Testing confidence</strong> | Low - cannot fully test at scale before cut-over | High - each piece tested in production |
| <strong>Rollback difficulty</strong> | Very difficult - reverse cut-over is risky | Easy - just route traffic back |
| <strong>Infrastructure complexity</strong> | Low - one environment to manage | High - two systems must coexist |
| <strong>Data migration complexity</strong> | High - one-shot migration must be perfect | Low - incremental, per-service |
| <strong>Organizational risk</strong> | High - single deadline creates pressure | Low - no single point of failure |
| <strong>Suitable for</strong> | Small systems, internal tools, greenfield | Large monoliths, critical systems, regulated industries |
| <strong>Feedback cycle</strong> | Months (after cut-over) | Days (each service goes live independently) |
| <strong>Coordination overhead</strong> | Low | High (routing, dual-writes, data sync) |
---
Decision framework: which strategy fits your situation?
Ask these questions in order
<strong>Question 1: Can we tolerate downtime?</strong> If your users accept scheduled downtime (internal tool, batch system, night-time maintenance): <strong>Big-Bang is viable</strong>. If downtime causes revenue loss, SLA breaches, or user churn: <strong>Strangler Fig is safer</strong>. <strong>Question 2: Is the system small enough to build in parallel?</strong> A 50,000-line monolith with 3 developers? Big-Bang might work. A 500,000-line monolith with 20 microservices worth of functionality? Big-Bang will almost certainly fail. Rule of thumb: if building the new system takes more than 3 months, Strangler Fig is better. <strong>Question 3: Can you test the new system adequately before cut-over?</strong> If the system has well-defined APIs and you can run integration tests that cover most scenarios, Big-Bang risk decreases. If the old system has untested codepaths and implicit behaviors, Strangler Fig lets you discover them incrementally. <strong>Question 4: Is the organization ready for an incremental migration?</strong> Strangler Fig requires discipline: dual-writing to databases, maintaining routing logic, coordinating across teams. If your organization cannot sustain a multi-quarter migration, Big-Bang may be the only option - but recognize the risk. <strong>Question 5: Are you migrating to a fundamentally different paradigm?</strong> If you are moving from a relational database to a document store, or from Java EE to Spring Boot, Big-Bang may be necessary because the paradigms are incompatible. Strangler Fig works best when the new system can coexist with the old one.
Decision matrix
┌──────────────────────────────────────┐
│ Can you tolerate downtime? │
│ │ │ │
│ YES NO │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────┐ ┌──────────────┐ │
│ │ Big-Bang│ │ Is system │ │
│ │ viable │ │ small? │ │
│ └─────────┘ │ │ │ │ │
│ │ YES NO │ │
│ │ │ │ │ │
│ │ ▼ ▼ │ │
│ │BB+edge SF │ │
│ │cases only │ │
└──────────────────────────────────────┘---
Java implementation: Strangler Fig with Spring Boot
This section walks through a concrete example of strangling an e-commerce monolith.
Step 1: Identify a bounded context to strangle
Your legacy monolith handles everything - products, cart, checkout, payments, notifications. You decide to start with <strong>search</strong>, because:
- It is relatively independent
- It has clear input/output (query → results)
- It can benefit from a dedicated search engine (Elasticsearch)
- It is high-traffic, so early benefits are visible
Step 2: Build the new search service
// SearchApplication.java - new Spring Boot service
@SpringBootApplication
@EnableDiscoveryClient
public class SearchApplication {
public static void main(String[] args) {
SpringApplication.run(SearchApplication.class, args);
}
}// ProductDocument.java - Elasticsearch document
@Document(indexName = "products")
public class ProductDocument {
@Id
private Long id;
private String name;
private String description;
private String category;
private double price;
private List<String> tags;
private double rating;
// constructors, getters, setters
}// SearchController.java - REST API for the new service
@RestController
@RequestMapping("/api/v2/search")
public class SearchController {
private final SearchService searchService;
public SearchController(SearchService searchService) {
this.searchService = searchService;
}
@GetMapping
public SearchResponse search(@RequestParam String q,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return searchService.search(q, page, size);
}
@PostMapping("/index")
public void indexProduct(@RequestBody ProductDocument product) {
searchService.index(product);
}
@PostMapping("/index/bulk")
public void bulkIndex(@RequestBody List<ProductDocument> products) {
searchService.bulkIndex(products);
}
}// SearchService.java - core business logic
@Service
public class SearchService {
private final ProductSearchRepository repository;
private final ProductEventPublisher eventPublisher;
public SearchService(ProductSearchRepository repository,
ProductEventPublisher eventPublisher) {
this.repository = repository;
this.eventPublisher = eventPublisher;
}
public SearchResponse search(String query, int page, int size) {
Pageable pageable = PageRequest.of(page, size);
Page<ProductDocument> results = repository
.findByNameContainingOrDescriptionContaining(query, query, pageable);
return new SearchResponse(
results.getContent(),
results.getTotalElements(),
results.getTotalPages(),
results.getNumber()
);
}
@Transactional
public void index(ProductDocument product) {
repository.save(product);
eventPublisher.publishProductIndexed(product.getId());
}
@Transactional
public void bulkIndex(List<ProductDocument> products) {
repository.saveAll(products);
}
}Step 3: Route search traffic at the API gateway
In your monolith, all requests go through a Spring Cloud Gateway or similar proxy. You configure it to route search requests to the new service.
// GatewayConfig.java - in the API gateway
@Configuration
public class GatewayConfig {
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
// Route search requests to the new service
.route("search-service", r -> r
.path("/api/v2/search/**")
.uri("lb://search-service"))
// Everything else goes to the old monolith
.route("legacy-monolith", r -> r
.path("/api/**")
.uri("lb://legacy-monolith"))
.build();
}
}Step 4: Dual-write data from the monolith to the new service
The search index needs to stay in sync with the monolith's product data. You implement a dual-write pattern:
// In the legacy monolith - publish events when product data changes
@Service
public class LegacyProductService {
private final ProductRepository productRepository;
private final MessagePublisher messagePublisher;
public LegacyProductService(ProductRepository productRepository,
MessagePublisher messagePublisher) {
this.productRepository = productRepository;
this.messagePublisher = messagePublisher;
}
@Transactional
public Product updateProduct(Long id, UpdateProductRequest request) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
product.setName(request.name());
product.setDescription(request.description());
product.setPrice(request.price());
product.setCategory(request.category());
Product saved = productRepository.save(product);
// Publish event for the search service to consume
messagePublisher.publish(new ProductUpdatedEvent(
saved.getId(),
saved.getName(),
saved.getDescription(),
saved.getPrice(),
saved.getCategory()
));
return saved;
}
}// Search service consumes the event and updates Elasticsearch
@Component
public class ProductEventConsumer {
private static final Logger log = LoggerFactory.getLogger(ProductEventConsumer.class);
private final ProductSearchRepository searchRepository;
public ProductEventConsumer(ProductSearchRepository searchRepository) {
this.searchRepository = searchRepository;
}
@EventListener
@Transactional
public void handleProductUpdated(ProductUpdatedEvent event) {
log.info("Indexing product {} from event", event.productId());
ProductDocument document = new ProductDocument(
event.productId(),
event.name(),
event.description(),
event.category(),
event.price(),
List.of(), // tags from another source
0.0 // rating from another source
);
searchRepository.save(document);
}
@EventListener
public void handleProductDeleted(ProductDeletedEvent event) {
log.info("Removing product {} from search index", event.productId());
searchRepository.deleteById(event.productId());
}
}Step 5: Backfill historical data
For existing products that were created before the search service existed, you run a backfill job:
@Component
public class SearchIndexBackfillJob {
private final ProductRepository productRepository;
private final SearchService searchService;
public SearchIndexBackfillJob(ProductRepository productRepository,
SearchService searchService) {
this.productRepository = productRepository;
this.searchService = searchService;
}
@Scheduled(cron = "0 0 2 * * ?") // Run at 2 AM daily
@Transactional
public void backfillMissingProducts() {
List<Product> products = productRepository.findAll();
List<ProductDocument> documents = products.stream()
.map(product -> new ProductDocument(
product.getId(),
product.getName(),
product.getDescription(),
product.getCategory(),
product.getPrice(),
product.getTags(),
product.getRating()
))
.toList();
searchService.bulkIndex(documents);
log.info("Backfill complete: {} products indexed", documents.size());
}
}Step 6: Verify and cut-over traffic gradually
Once the search service is running and catching events, you can test it:
- <strong>Shadow traffic</strong>: duplicate search requests to both old and new, compare results (do not serve new results to users yet).
- <strong>Canary release</strong>: route 5% of search traffic to the new service, monitor errors and latency.
- <strong>Gradual ramp-up</strong>: increase to 25%, 50%, 75%, monitoring at each step.
- <strong>Full cut-over</strong>: 100% of search traffic goes to the new service.
// Shadow traffic filter - in the API gateway
@Component
public class ShadowTrafficFilter implements GlobalFilter {
private final RestTemplate restTemplate;
public ShadowTrafficFilter(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String path = request.getURI().getPath();
if (path.startsWith("/api/search")) {
// Forward a copy of the request to the new service (shadow)
URI shadowUri = URI.create("http://search-service/api/v2/search" +
request.getURI().getQuery());
restTemplate.exchange(shadowUri, HttpMethod.GET, null, String.class)
.subscribe(
response -> log.info("Shadow search succeeded: {}", response.getBody()),
error -> log.warn("Shadow search failed (expected): {}", error.getMessage())
);
}
return chain.filter(exchange);
}
}Step 7: Remove old search code from the monolith
Once the new search service is stable for all traffic, delete the search-related code from the monolith. This is the actual "strangling" step - the old code is removed, and the new service is the only path. ---
Java implementation: Big-Bang with Spring Boot
This example shows a Big-Bang migration for a <strong>small inventory management system</strong> moving from a legacy JDBC-based application to Spring Boot.
The scenario
- A warehouse inventory system used internally
- ~15,000 lines of Java code with JDBC and JSP
- Downtime of 4 hours is acceptable (weekend maintenance window)
- Moving from raw JDBC to Spring Boot + JPA + Thymeleaf
The cut-over plan
// Step 1: Database migration script (Flyway)
// V1__initial_schema.sql
//
// CREATE TABLE products (
// id BIGINT AUTO_INCREMENT PRIMARY KEY,
// sku VARCHAR(50) NOT NULL UNIQUE,
// name VARCHAR(200) NOT NULL,
// description TEXT,
// quantity INT NOT NULL DEFAULT 0,
// price DECIMAL(10,2) NOT NULL,
// created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
// updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
// );
//
// CREATE TABLE orders (
// id BIGINT AUTO_INCREMENT PRIMARY KEY,
// product_id BIGINT NOT NULL,
// quantity INT NOT NULL,
// total DECIMAL(10,2) NOT NULL,
// status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
// created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
// FOREIGN KEY (product_id) REFERENCES products(id)
// );// Step 2: Spring Boot application
@SpringBootApplication
@EnableTransactionManagement
public class InventoryApplication {
public static void main(String[] args) {
SpringApplication.run(InventoryApplication.class, args);
}
}// Step 3: Entity classes (JPA)
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String sku;
@Column(nullable = false, length = 200)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(nullable = false)
private int quantity;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
// constructors, getters, setters
}// Step 4: Repository layer
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
Optional<Product> findBySku(String sku);
List<Product> findByQuantityLessThan(int threshold);
}// Step 5: Service layer
@Service
@Transactional
public class InventoryService {
private final ProductRepository productRepository;
private final OrderRepository orderRepository;
public InventoryService(ProductRepository productRepository,
OrderRepository orderRepository) {
this.productRepository = productRepository;
this.orderRepository = orderRepository;
}
public Product addStock(String sku, int quantity) {
Product product = productRepository.findBySku(sku)
.orElseThrow(() -> new IllegalArgumentException("Unknown SKU: " + sku));
product.setQuantity(product.getQuantity() + quantity);
return productRepository.save(product);
}
public Order createOrder(String sku, int quantity) {
Product product = productRepository.findBySku(sku)
.orElseThrow(() -> new IllegalArgumentException("Unknown SKU: " + sku));
if (product.getQuantity() < quantity) {
throw new InsufficientStockException(
"Insufficient stock for SKU " + sku +
". Available: " + product.getQuantity() +
", requested: " + quantity);
}
product.setQuantity(product.getQuantity() - quantity);
productRepository.save(product);
Order order = new Order(product, quantity,
product.getPrice().multiply(BigDecimal.valueOf(quantity)));
return orderRepository.save(order);
}
}// Step 6: Controller
@Controller
@RequestMapping("/inventory")
public class InventoryController {
private final InventoryService inventoryService;
public InventoryController(InventoryService inventoryService) {
this.inventoryService = inventoryService;
}
@GetMapping
public String listProducts(Model model) {
model.addAttribute("products", productRepository.findAll());
return "inventory/list";
}
@PostMapping("/add-stock")
public String addStock(@RequestParam String sku,
@RequestParam int quantity) {
inventoryService.addStock(sku, quantity);
return "redirect:/inventory";
}
}The cut-over checklist
┌────────────────────────────────────────────────────────────┐
│ BIG-BANG CUT-OVER CHECKLIST │
├────────────────────────────────────────────────────────────┤
│ │
│ [ ] Database schema migrated and verified (dry run) │
│ [ ] Data migration script run and validated │
│ [ ] New application deployed to staging │
│ [ ] Integration tests pass against migrated data │
│ [ ] Smoke tests pass (critical user journeys) │
│ [ ] Rollback plan documented (reverse migration script) │
│ [ ] Monitoring dashboards set up │
│ [ ] Alerts configured for error rates and latency │
│ [ ] Support team briefed on cut-over plan │
│ [ ] Communication sent to users (scheduled downtime) │
│ │
│ --- CUT-OVER WINDOW STARTS (Saturday 10 PM) --- │
│ │
│ [ ] Disable writes to old system │
│ [ ] Run final data migration │
│ [ ] Verify data integrity │
│ [ ] Deploy new application │
│ [ ] Smoke tests pass │
│ [ ] Enable writes to new system │
│ [ ] Monitor for 30 minutes │
│ [ ] Declare cut-over successful OR trigger rollback │
│ │
│ --- CUT-OVER WINDOW ENDS (Sunday 2 AM) --- │
│ │
│ [ ] Post-migration monitoring (48 hours) │
│ [ ] Decommission old system │
│ │
└────────────────────────────────────────────────────────────┘---
The intermediate state: dual-write patterns
Both migration strategies require handling data consistency during the transition period. The most critical pattern is <strong>dual-write</strong>.
Dual-write with transactional outbox
When the monolith writes data that the new service also needs, you cannot afford to lose events. The <strong>transactional outbox pattern</strong> ensures every state change in the monolith produces a corresponding event:
// In the legacy monolith
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String description;
private double price;
// ... other fields
}
// Outbox table stores events in the same database transaction
@Entity
@Table(name = "outbox_events")
public class OutboxEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String aggregateType; // e.g., "Product"
@Column(nullable = false)
private String aggregateId; // e.g., "123"
@Column(nullable = false)
private String eventType; // e.g., "ProductUpdated"
@Column(columnDefinition = "jsonb", nullable = false)
private String payload; // JSON of the event data
@Column(nullable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private boolean published = false;
}// Service that writes to both the entity table and the outbox in one transaction
@Service
public class ProductService {
private final ProductRepository productRepository;
private final OutboxEventRepository outboxRepository;
public ProductService(ProductRepository productRepository,
OutboxEventRepository outboxRepository) {
this.productRepository = productRepository;
this.outboxRepository = outboxRepository;
}
@Transactional
public Product updateProduct(Long id, UpdateProductRequest request) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
product.setName(request.name());
product.setDescription(request.description());
product.setPrice(request.price());
Product saved = productRepository.save(product);
// Write to outbox in the SAME transaction
OutboxEvent event = new OutboxEvent();
event.setAggregateType("Product");
event.setAggregateId(String.valueOf(saved.getId()));
event.setEventType("ProductUpdated");
// Serialize event payload
try {
ObjectMapper mapper = new ObjectMapper();
event.setPayload(mapper.writeValueAsString(Map.of(
"productId", saved.getId(),
"name", saved.getName(),
"description", saved.getDescription(),
"price", saved.getPrice()
)));
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize outbox event", e);
}
event.setCreatedAt(LocalDateTime.now());
outboxRepository.save(event);
return saved;
}
}// Outbox poller - publishes pending events to the message broker
@Component
public class OutboxPoller {
private final OutboxEventRepository outboxRepository;
private final MessagePublisher messagePublisher;
public OutboxPoller(OutboxEventRepository outboxRepository,
MessagePublisher messagePublisher) {
this.outboxRepository = outboxRepository;
this.messagePublisher = messagePublisher;
}
@Scheduled(fixedDelay = 1000) // Poll every second
@Transactional
public void publishPendingEvents() {
List<OutboxEvent> pendingEvents = outboxRepository
.findByPublishedFalseOrderByCreatedAtAsc();
for (OutboxEvent event : pendingEvents) {
try {
messagePublisher.publish(event.getEventType(), event.getPayload());
event.setPublished(true);
outboxRepository.save(event);
} catch (Exception e) {
log.error("Failed to publish outbox event {}: {}",
event.getId(), e.getMessage());
// Will retry on next poll
}
}
}
}For a deeper explanation of this pattern, see <strong>outbox-pattern-vs-dual-writes</strong>. ---
Anti-patterns to avoid
Anti-pattern 1: The "big rewrite"
Building an entire new system in secret, then switching overnight. This is Big-Bang without the benefits of Big-Bang (speed) and with all the risks. Symptoms:
- The "new system" team has no contact with production
- No incremental delivery milestones
- The cut-over date keeps slipping
- Old system changes are rejected because "we are migrating anyway"
Fix: ship the new system incrementally, even if it means some intermediate states are ugly.
Anti-pattern 2: Strangling everything simultaneously
Extracting five services at once from a monolith. Each extraction is risky independently; doing them in parallel multiplies the risk. Symptoms:
- Multiple dual-write paths active at the same time
- Cross-service dependencies created before any single service is stable
- Team cannot tell which extraction caused an incident
Fix: strangle one bounded context at a time. Stabilize, then move to the next.
Anti-pattern 3: No rollback plan
Assuming the migration will succeed and not preparing for failure. Symptoms:
- No reverse migration script
- No documented rollback steps
- "We will fix it in production" attitude
Fix: every migration step must have a documented, tested rollback. If reverse migration takes longer than the forward migration, the plan is not ready.
Anti-pattern 4: Strangling the database first
Extracting the database before the application code. This forces every service to coordinate around a single shared database, creating a distributed monolith. Symptoms:
- Services share database connections
- Schema changes require coordination across teams
- "The database is the integration point" becomes the excuse for not decoupling
Fix: strangle the application code first, then consider database decomposition.
Anti-pattern 5: Perfect symmetry
Trying to make the new system behave exactly like the old one, including all its bugs and undocumented behaviors. Symptoms:
- Massive test suites that replicate old system behavior
- "The old system does this weird thing, so we must too"
- Never actually improving the system, just reimplementing it
Fix: accept that migration is an opportunity to fix past mistakes. Document intentional differences and their rationale. ---
Case studies
Netflix: Strangler Fig from datacenter to AWS (2008-2016)
Netflix started as a monolith running in a single datacenter. They migrated to AWS using a Strangler Fig approach:
- <strong>Strangled the recommendation engine first</strong> - a relatively independent service with clear inputs/outputs.
- <strong>Added an API gateway</strong> that could route requests to either the monolith or new services.
- <strong>Gradually extracted services</strong>: user management, content delivery, billing, encoding.
- <strong>Used chaos engineering</strong> (Chaos Monkey) to verify resilience at each step.
The migration took years, but Netflix never had a "cut-over weekend." Each service was extracted, tested, and stabilized independently. <strong>Key lesson</strong>: Strangler Fig allowed Netflix to grow their cloud infrastructure alongside the datacenter, with zero downtime for users.
Uber: Big-Bang from monolith to microservices (2014)
Uber's original monolith (written in Python) handled dispatch, billing, payments, notifications, and more. They attempted a Big-Bang rewrite to microservices. <strong>What happened</strong>: the migration was technically successful, but the timeline stretched far beyond estimates. The monolith had accumulated implicit behaviors that the new system had to replicate, and the cut-over required months of stabilization. <strong>Key lesson</strong>: even successful Big-Bang migrations are expensive. Uber has since adopted Strangler Fig for subsequent migrations.
Amazon: Strangler Fig from legacy to service-oriented (2001-2003)
Amazon CEO Jeff Bezos mandated in 2002 that all teams must communicate through service interfaces and that any team could call any other team's service. This was effectively a Strangler Fig migration to a service-oriented architecture.
- Each feature was extracted independently
- The API gateway (internal) routed to old or new
- Old code was deleted only when all callers migrated
- No single cut-over - the migration took years
<strong>Key lesson</strong>: the organizational mandate (all communication through APIs) forced the technical migration to happen incrementally. ---
Practical guidelines for your migration
If you choose Strangler Fig
- <strong>Start with the easiest bounded context</strong>, not the most impactful one. Learn the pattern with low risk first.
- <strong>Invest in observability early</strong>. You need distributed tracing across the old and new systems to debug issues.
- <strong>Keep strangling steps small</strong>. Each step should take days, not months. If a step takes more than two weeks, break it down.
- <strong>Do not share databases</strong> between old and new systems. The outbox pattern is better than dual-writes to the same database.
- <strong>Delete old code aggressively</strong>. Once a service is stable for all traffic, remove the old implementation. Accumulated dead code is expensive to maintain.
- <strong>Document the strangler boundary</strong>. New team members need to know which features are in the old system and which are in the new one.
If you choose Big-Bang
- <strong>Do dry runs</strong>. Practice the cut-over in a staging environment at least three times. Each dry run will reveal problems.
- <strong>Invest in data migration tooling</strong>. The data migration is the highest-risk part of Big-Bang. Use tools like Flyway or Liquibase and test with production-sized datasets.
- <strong>Plan for the reverse cut-over</strong>. If the migration goes wrong, how long to revert? If it takes longer than the original cut-over, the plan is too risky.
- <strong>Freeze feature development</strong>. No new features on either system for at least two weeks before cut-over. You need stability before the switch.
- <strong>Monitor aggressively after cut-over</strong>. Set up dashboards for error rates, latency, and data integrity checks. Have the entire team on call for at least 48 hours.
---
Summary: which strategy wins?
There is no universal winner. The right choice depends on context:
| Your situation | Recommended strategy |
|---|---|
| Small system (< 50K LOC), internal users, weekend downtime acceptable | Big-Bang |
| Large monolith (> 200K LOC), customer-facing, zero downtime | Strangler Fig |
| Legacy system at end-of-life, must decommission quickly | Big-Bang (with careful planning) |
| Growing system, no immediate deadline, improving architecture | Strangler Fig |
| New team taking over an unfamiliar codebase | Strangler Fig (learn incrementally) |
| Regulatory deadline (e.g., PCI compliance by a fixed date) | Big-Bang (deadline forces cut-over) |
A heuristic
If you can describe the new system's complete architecture in one whiteboard session, Big-Bang is feasible. If the whiteboard session requires multiple diagrams and a "we will figure that out later" caveat, use Strangler Fig.
Final thought
The goal of any migration is to <strong>improve the system without breaking it</strong>. Strangler Fig optimizes for safety and learning. Big-Bang optimizes for speed and simplicity. Both are valid - but the cost of getting Big-Bang wrong is significantly higher. When in doubt, strangle. You can always accelerate later, but you cannot undo a failed cut-over without significant damage. ---
Diagram: Strangler Fig migration phases at a glance
PHASE 0 ──► All traffic goes to Old Monolith
[Users] ────► [Old Monolith]
PHASE 1 ──► First service extracted (Search)
[Users] ──► [Router] ──┬──► [Search Service] (5% traffic)
└──► [Old Monolith] (95% traffic)
PHASE 2 ──► Multiple services extracted
[Users] ──► [Router] ──┬──► [Search Service] (100%)
├──► [Auth Service] (100%)
├──► [Payment Service] (100%)
└──► [Old Monolith] (remaining features)
PHASE 3 ──► Old system decommissioned
[Users] ──► [Router] ──► [Search Service]
[Auth Service]
[Payment Service]
[Order Service]
[... new services]
[Old Monolith] ──► Shut down ✓Diagram: Big-Bang migration phases at a glance
PHASE 0 ──► Build new system in parallel
[Old Monolith] ──► Production traffic
[New System] ──► Being built (no traffic)
PHASE 1 ──► Cut-over window
[Old Monolith] ──► Writes disabled ──► Data migration
[New System] ──► Deployed
PHASE 2 ──► Production switch
[Users] ──► [New System] (100% traffic)
[Old Monolith] ──► On standby (rollback available)
PHASE 3 ──► Stabilization and decommission
[Users] ──► [New System]
[Old Monolith] ──► Decommissioned