How we replaced a live Java monolith with the strangler fig pattern

Isometric illustration: a dark monolithic block being enveloped by a growing cyan lattice of roots taking over its structural load

Your Java monolith processes 2,000 requests per second. Business says: modernize it. Also business says: zero downtime. The Strangler Fig pattern is how you do both – and we have the scars to prove it.

The Problem: A Monolith You Can’t Turn Off

We’ve seen this pattern dozens of times. A Java monolith, built years ago on Spring MVC or plain servlets, runs the entire business. Order processing, reporting, user management, notifications. Everything in one deployable WAR file.

The monolith works. That’s the problem. It works well enough that nobody can justify taking it offline for a rewrite. But it’s slow to deploy, impossible to scale selectively, and every change risks breaking something unrelated. Sound familiar?

A full rewrite is a fantasy. Rewrites take 2-3x longer than estimated, and you’re maintaining two systems in parallel the entire time. The industry failure rate for big-bang rewrites is brutal. You need a strategy that lets you replace the monolith piece by piece, while it keeps running.

Enter the Strangler Fig pattern.

What Is the Strangler Fig Pattern?

Martin Fowler named this pattern after the strangler fig tree, which grows around a host tree, gradually replacing it until the original tree is gone. The software version works the same way: you build new functionality around the old system, intercept calls at the edge, and route them to new services one by one.

Three phases:

  1. Transform – Build the new service that replaces one bounded context from the monolith.
  2. Coexist – Run old and new side by side. A routing layer (the “strangler facade”) decides which handles each request.
  3. Eliminate – Once the new service is proven in production, remove the old code path.

The key insight: you never rewrite the whole system. You strangle it incrementally. At any point, you can stop, and you still have a working system – part old, part new.

The Architecture: Facade + Router + Feature Flags

In a Java/Spring ecosystem, the strangler facade is typically an API gateway or a reverse proxy sitting in front of both the monolith and your new services. We use Spring Cloud Gateway for this – it gives you programmatic route control with the full Spring ecosystem behind it.

Here’s the high-level architecture:

                    ┌─────────────────────┐
   Client Request → │  Spring Cloud Gateway │
                    │   (Strangler Facade)  │
                    └──────┬───────┬────────┘
                           │       │
                    ┌──────▼──┐ ┌──▼──────────┐
                    │ Monolith │ │ New Service  │
                    │ (legacy) │ │ (Spring Boot)│
                    └──────┬──┘ └──┬───────────┘
                           │       │
                    ┌──────▼───────▼────────┐
                    │   Shared Database      │
                    │   (migrated later)     │
                    └────────────────────────┘

The gateway inspects incoming requests and decides: does this go to the monolith, or to the new service? Initially, everything goes to the monolith. As you extract services, you flip routes one at a time.

Java Example: Building the Strangler Facade

Let’s say your monolith has a reporting module at /api/reports/*. You’ve built a new Spring Boot microservice to replace it. Here’s how you configure the gateway to route traffic:

Step 1: Gateway Route Configuration

@Configuration
public class StranglerRouteConfig {

    @Bean
    public RouteLocator stranglerRoutes(RouteLocatorBuilder builder) {
        return builder.routes()

            // NEW: reporting requests go to the extracted service
            .route("reports-new", r -> r
                .path("/api/reports/**")
                .and()
                .header("X-Feature-Flag", "new-reports")
                .uri("http://reporting-service:8081"))

            // FALLBACK: everything else goes to the monolith
            .route("monolith-fallback", r -> r
                .path("/api/**")
                .uri("http://monolith:8080"))

            .build();
    }
}

Notice the feature flag header. During the coexist phase, you can route a percentage of traffic to the new service, or limit it to internal users, before going 100%.

Step 2: Percentage-Based Routing with a Custom Filter

Feature flag headers work for testing, but for a gradual rollout you want percentage-based routing. Here’s a custom gateway filter that sends a configurable percentage of traffic to the new service:

@Component
public class CanaryRoutingFilter implements GatewayFilterFactory<CanaryRoutingFilter.Config> {

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            int roll = ThreadLocalRandom.current().nextInt(100);

            if (roll < config.getNewServicePercentage()) {
                // Route to the new reporting service
                URI newUri = UriComponentsBuilder
                    .fromUri(exchange.getRequest().getURI())
                    .host("reporting-service")
                    .port(8081)
                    .build()
                    .toUri();

                ServerHttpRequest mutatedRequest = exchange.getRequest()
                    .mutate()
                    .uri(newUri)
                    .build();

                return chain.filter(
                    exchange.mutate().request(mutatedRequest).build()
                );
            }

            // Default: monolith handles it
            return chain.filter(exchange);
        };
    }

    @Data
    public static class Config {
        private int newServicePercentage = 0; // start at 0%, ramp up
    }
}

Start at 0%. Bump to 5%, monitor error rates and latency. Then 25%, 50%, 100%. If something breaks, set it back to 0% - the monolith is still there, handling everything. Zero downtime.

Step 3: The Anti-Corruption Layer

The monolith's data model is probably a mess of JPA entities with deep inheritance hierarchies and bidirectional relationships. Don't let that leak into your new service. Build an anti-corruption layer (ACL) that translates between the old and new domain models:

@Service
public class LegacyReportAdapter {

    private final LegacyReportClient legacyClient;

    /**
     * Translates the monolith's LegacyReportDTO into our clean domain model.
     * The monolith returns nested Objects and nullable fields everywhere - 
     * we enforce structure here so the new service never sees that mess.
     */
    public Report fromLegacy(LegacyReportDTO legacy) {
        return Report.builder()
            .id(UUID.fromString(legacy.getReportId()))
            .title(legacy.getTitle())
            .generatedAt(Instant.parse(legacy.getTimestamp()))
            .metrics(legacy.getData().entrySet().stream()
                .map(e -> new Metric(e.getKey(), parseDecimal(e.getValue())))
                .toList())
            .status(mapStatus(legacy.getStatusCode()))
            .build();
    }

    private ReportStatus mapStatus(int legacyCode) {
        return switch (legacyCode) {
            case 0 -> ReportStatus.DRAFT;
            case 1 -> ReportStatus.PUBLISHED;
            case 2 -> ReportStatus.ARCHIVED;
            default -> ReportStatus.UNKNOWN;
        };
    }
}

This adapter is the boundary. Your new service speaks clean domain language. The legacy system's quirks stay quarantined behind this layer. When you eventually decommission the monolith, you delete the adapter - everything else stays clean.

Real-World Timeline: The Dango Engagement

This isn't theory. We applied this exact approach for Dango, an education analytics company whose Java monolith powered all their reporting, user management, and data ingestion. The reporting module alone took analysts 4 days per reporting cycle - manual data pulls, brittle scheduled jobs, reports breaking when upstream schemas changed.

Here's how the 12-week engagement broke down:

Weeks 1-2: Domain mapping. We mapped every inbound request to the monolith, identified the reporting module as the highest-value extraction target (most pain, clearest bounded context), and defined the new service's API contract.

Weeks 3-5: Build the new service. A Spring Boot application with a clean domain model, proper event sourcing for report generation, and the anti-corruption layer to read legacy data during the transition. The gateway routed 0% to it - but it was deployed and receiving shadow traffic for validation.

Weeks 6-8: Gradual rollout. 5% → 25% → 50% → 100% of reporting traffic migrated to the new service. We monitored error rates, latency percentiles, and data consistency at every step. Two rollbacks during this phase - both caught by automated alerts within minutes, both resolved within the hour.

Weeks 9-10: Data migration. With all traffic on the new service, we migrated the reporting data out of the monolith's shared database into a dedicated PostgreSQL instance. Event replay ensured zero data loss.

Weeks 11-12: Cleanup and handoff. Removed the dead reporting code from the monolith (12,000 lines deleted). Updated documentation. Trained the client's team on the new deployment pipeline.

The results:

  • 75% reduction in reporting time, from 4 days to 1 day per cycle
  • 120 hours saved per month across the analytics team
  • Zero production incidents during the migration
  • Independent deployment, the reporting service now ships 3x per week, decoupled from the monolith's monthly release cycle

When to Use the Strangler Fig Pattern (and When Not To)

Use it when:

  • The monolith is in production and downtime isn't an option
  • You can identify clear bounded contexts to extract
  • The team can maintain two systems temporarily
  • You have (or can build) an API gateway or reverse proxy layer

Skip it when:

  • The monolith is small enough for a weekend rewrite
  • There's no clear module boundary - everything is spaghetti all the way down
  • The system is offline/batch-only and downtime is acceptable
  • You're extracting a single function, not a bounded context (use Branch by Abstraction instead)

Common Mistakes We've Seen

1. Skipping the anti-corruption layer. Teams connect the new service directly to the monolith's database. Now you have two services coupled to one schema. When the monolith changes a column, both break. Always build the ACL - it's cheap insurance.

2. Extracting too much at once. The pattern works because each extraction is small and reversible. If you try to extract three modules simultaneously, you lose the safety net. One bounded context at a time.

3. Leaving the dead code. After routing 100% of traffic to the new service, teams forget to delete the old code from the monolith. Six months later, someone accidentally re-enables it. Delete the old path the same sprint you complete the migration.

4. No rollback plan. If you can't set the percentage back to 0% and have the monolith take over in under a minute, your facade isn't doing its job. Test the rollback before you start the rollout.

The Payoff: What You Get After Strangling the Monolith

After extracting 2-3 bounded contexts, the benefits compound:

  • Faster deployments. Each service deploys independently. No more coordinating a 47-service WAR file release.
  • Targeted scaling. Your reporting service needs more CPU during month-end? Scale just that service, not the entire monolith.
  • Smaller blast radius. A bug in reporting doesn't take down user authentication.
  • Team autonomy. Different teams own different services. They choose their own release cadence, testing strategy, even their own database.

The monolith gets smaller with each extraction. Eventually, what's left is small enough to rewrite over a weekend - or just leave running. Some of our clients still have a "core monolith" handling 20% of original functionality, and that's fine. The strangler fig doesn't require you to kill the host tree. It just makes sure it's no longer the single point of failure.


Running a Java monolith that's holding your team back? We've done this migration for companies like Dango - zero downtime, measurable results.