Strangler Fig vs Big-Bang: Choosing the Right Migration Strategy for Java Applications

An in-depth, decision-focused guide comparing Strangler Fig and Big-Bang migration strategies for Java monoliths, with real code examples, risk analysis, and proven rollout patterns.

Jul 20, 202617 min read

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 &quot;Strangler Fig&quot;?

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 &quot;cut-over&quot; or &quot;flash-cut&quot;) 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

  1. <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.
  1. <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.
  1. <strong>Testing impossibility</strong>: you cannot fully test a system at production scale before switching. Edge cases always emerge after cut-over.
  1. <strong>Organizational pressure</strong>: the cut-over date creates deadline stress. Teams rush, cut corners, and ship with known issues because &quot;we cannot delay the switch.&quot;
  1. <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

  1. <strong>Identify a bounded context</strong> within the old system (e.g., search, authentication, payments).
  2. <strong>Build a new service</strong> that implements that feature independently.
  3. <strong>Route traffic</strong> for that feature to the new service (using a proxy, gateway, or feature flag).
  4. <strong>Verify correctness</strong> in production with real traffic but limited blast radius.
  5. <strong>Migrate data</strong> if needed (dual-write or backfill).
  6. <strong>Remove the old code</strong> once the new service is stable.
  7. 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

DimensionBig-BangStrangler Fig
<strong>Risk profile</strong>High - all eggs in one basketLow - 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 freezesContinues normally
<strong>Testing confidence</strong>Low - cannot fully test at scale before cut-overHigh - each piece tested in production
<strong>Rollback difficulty</strong>Very difficult - reverse cut-over is riskyEasy - just route traffic back
<strong>Infrastructure complexity</strong>Low - one environment to manageHigh - two systems must coexist
<strong>Data migration complexity</strong>High - one-shot migration must be perfectLow - incremental, per-service
<strong>Organizational risk</strong>High - single deadline creates pressureLow - no single point of failure
<strong>Suitable for</strong>Small systems, internal tools, greenfieldLarge monoliths, critical systems, regulated industries
<strong>Feedback cycle</strong>Months (after cut-over)Days (each service goes live independently)
<strong>Coordination overhead</strong>LowHigh (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

java
// SearchApplication.java - new Spring Boot service
@SpringBootApplication
@EnableDiscoveryClient
public class SearchApplication {
    public static void main(String[] args) {
        SpringApplication.run(SearchApplication.class, args);
    }
}
java
// 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
}
java
// 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);
    }
}
java
// 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.

java
// 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&#39;s product data. You implement a dual-write pattern:

java
// 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;
    }
}
java
// 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:

java
@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:

  1. <strong>Shadow traffic</strong>: duplicate search requests to both old and new, compare results (do not serve new results to users yet).
  2. <strong>Canary release</strong>: route 5% of search traffic to the new service, monitor errors and latency.
  3. <strong>Gradual ramp-up</strong>: increase to 25%, 50%, 75%, monitoring at each step.
  4. <strong>Full cut-over</strong>: 100% of search traffic goes to the new service.
java
// 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 &quot;strangling&quot; 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

java
// 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)
// );
java
// Step 2: Spring Boot application
@SpringBootApplication
@EnableTransactionManagement
public class InventoryApplication {
    public static void main(String[] args) {
        SpringApplication.run(InventoryApplication.class, args);
    }
}
java
// 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
}
java
// Step 4: Repository layer
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
    Optional<Product> findBySku(String sku);
    List<Product> findByQuantityLessThan(int threshold);
}
java
// 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);
    }
}
java
// 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:

java
// 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;
}
java
// 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;
    }
}
java
// 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 &quot;big rewrite&quot;

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 &quot;new system&quot; team has no contact with production
  • No incremental delivery milestones
  • The cut-over date keeps slipping
  • Old system changes are rejected because &quot;we are migrating anyway&quot;

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
  • &quot;We will fix it in production&quot; 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
  • &quot;The database is the integration point&quot; 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
  • &quot;The old system does this weird thing, so we must too&quot;
  • 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:

  1. <strong>Strangled the recommendation engine first</strong> - a relatively independent service with clear inputs/outputs.
  2. <strong>Added an API gateway</strong> that could route requests to either the monolith or new services.
  3. <strong>Gradually extracted services</strong>: user management, content delivery, billing, encoding.
  4. <strong>Used chaos engineering</strong> (Chaos Monkey) to verify resilience at each step.

The migration took years, but Netflix never had a &quot;cut-over weekend.&quot; 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&#39;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&#39;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

  1. <strong>Start with the easiest bounded context</strong>, not the most impactful one. Learn the pattern with low risk first.
  2. <strong>Invest in observability early</strong>. You need distributed tracing across the old and new systems to debug issues.
  3. <strong>Keep strangling steps small</strong>. Each step should take days, not months. If a step takes more than two weeks, break it down.
  4. <strong>Do not share databases</strong> between old and new systems. The outbox pattern is better than dual-writes to the same database.
  5. <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.
  6. <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

  1. <strong>Do dry runs</strong>. Practice the cut-over in a staging environment at least three times. Each dry run will reveal problems.
  2. <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.
  3. <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.
  4. <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.
  5. <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 situationRecommended strategy
Small system (< 50K LOC), internal users, weekend downtime acceptableBig-Bang
Large monolith (> 200K LOC), customer-facing, zero downtimeStrangler Fig
Legacy system at end-of-life, must decommission quicklyBig-Bang (with careful planning)
Growing system, no immediate deadline, improving architectureStrangler Fig
New team taking over an unfamiliar codebaseStrangler 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&#39;s complete architecture in one whiteboard session, Big-Bang is feasible. If the whiteboard session requires multiple diagrams and a &quot;we will figure that out later&quot; 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