Available Hire Me
← All Writing Architecture

The Strangler Fig Pattern — Migrating a Monolith to Microservices

A practical guide to the Strangler Fig pattern — incrementally extracting services from a monolith with a Java Spring Boot proxy facade and feature toggles.

The strangler fig is a tropical plant that grows around a host tree, slowly encircling and eventually replacing it — all without the host tree ever falling over. Martin Fowler used it in 2004 to name the pattern for incremental system replacement: rather than a risky big-bang rewrite, you wrap the existing system with new behaviour, route traffic progressively, and let the old code die naturally once it’s no longer serving requests.

After 25 years in enterprise Java, I’ve seen big-bang rewrites fail more often than they succeed. The Strangler Fig pattern is the pragmatic alternative — it keeps the system live throughout migration, provides a rollback path at every step, and lets you validate the new service before you commit to it.

This post covers how to implement Strangler Fig in a Java/Spring Boot context — from identifying your first extraction target to wiring the proxy facade and routing traffic incrementally.

Why big-bang rewrites fail

A monolith rewrite typically starts optimistically: “we understand the domain well, we’ll just do it better this time.” Six months in, the new system has 60% of the features and 40% of the edge cases. Business pressure forces a hard cutover date. The cutover reveals the remaining 40%, customer-reported bugs spike, and you spend three months fire-fighting while the old system is in emergency maintenance.

The Strangler Fig avoids this by never having a cutover. At every moment, the system is live. New behaviour is introduced incrementally and validated in production before old code is removed.

Identifying the first extraction target

Not every component is worth extracting first. The ideal candidate:

  • Has a clear bounded context with minimal data coupling to the rest of the monolith
  • Has high change velocity — teams are constantly blocked by monolith deploy cycles
  • Has a definable API contract that can be expressed as HTTP or async messages
  • Is not on the critical path for every request, so isolated failures don’t cascade

A common first extraction from a trading or financial monolith is an account/balance service or a notification service — both have clear boundaries and non-catastrophic failure modes.

The proxy facade

The Strangler Fig requires a proxy that sits in front of both the monolith and the new service. Initially all traffic passes through to the monolith. As confidence in the new service grows, traffic shifts progressively.

In a Spring Boot monolith you can implement this with a @RestController proxy that delegates to either the old service layer or the new external service:

@RestController
@RequestMapping("/api/accounts")
public class AccountFacadeController {

    private final LegacyAccountService legacy;
    private final NewAccountServiceClient newService;
    private final MigrationFeatureFlags flags;

    @GetMapping("/{id}/balance")
    public ResponseEntity<BalanceResponse> getBalance(@PathVariable String id) {
        if (flags.isNewAccountServiceEnabled(id)) {
            return newService.getBalance(id);
        }
        return ResponseEntity.ok(legacy.getBalance(id));
    }

    @PostMapping("/{id}/deposit")
    public ResponseEntity<Void> deposit(@PathVariable String id,
                                         @RequestBody DepositRequest req) {
        if (flags.isNewAccountServiceEnabled(id)) {
            return newService.deposit(id, req);
        }
        legacy.deposit(id, req.amount());
        return ResponseEntity.ok().build();
    }
}

MigrationFeatureFlags controls which path handles a given request. At the start it returns false for every account. As you validate the new service, you enable it for a growing cohort.

Feature flag implementation

A simple flag implementation that enables the new service for a percentage of accounts:

@Component
public class MigrationFeatureFlags {

    @Value("${migration.account-service.enabled:false}")
    private boolean globalEnabled;

    @Value("${migration.account-service.rollout-percent:0}")
    private int rolloutPercent;

    private final Set<String> explicitEnableList = ConcurrentHashMap.newKeySet();

    public boolean isNewAccountServiceEnabled(String accountId) {
        if (!globalEnabled) return false;
        if (explicitEnableList.contains(accountId)) return true;
        return deterministicPercent(accountId) < rolloutPercent;
    }

    public void enable(String accountId) {
        explicitEnableList.add(accountId);
    }

    private int deterministicPercent(String accountId) {
        return Math.abs(accountId.hashCode() % 100);
    }
}

Deterministic hashing ensures the same account always routes to the same service — avoiding the split-brain problem where read and write operations go to different systems for the same aggregate.

Shadow mode — dark launch

Before routing real traffic to the new service, run it in shadow mode: send every request to both services, return the legacy response, and compare results asynchronously.

@Component
public class ShadowAccountService {

    private final LegacyAccountService legacy;
    private final NewAccountServiceClient newService;
    private final ExecutorService shadow = Executors.newVirtualThreadPerTaskExecutor();

    public BalanceResponse getBalance(String accountId) {
        var legacyResult = legacy.getBalance(accountId);

        shadow.submit(() -> {
            try {
                var newResult = newService.getBalance(accountId).getBody();
                if (!legacyResult.equals(newResult)) {
                    log.warn("Shadow divergence for account {}: legacy={} new={}",
                        accountId, legacyResult, newResult);
                    Metrics.counter("account.shadow.divergence").increment();
                }
            } catch (Exception e) {
                Metrics.counter("account.shadow.error").increment();
            }
        });

        return legacyResult;
    }
}

Run shadow mode for a week across all accounts. When divergence drops to zero and error rates are negligible, you have the confidence to start live routing. Virtual threads make this cheap — no thread pool tuning required.

The anti-corruption layer

When the new service has a different domain model from the monolith — different terminology, field names, or validation rules — add an anti-corruption layer between the facade and the new service client:

@Component
public class AccountServiceAdapter {

    private final NewAccountServiceClient client;

    public BalanceResponse getBalance(String accountId) {
        // Monolith uses "accountId"; new service uses "customerId"
        var response = client.getCustomerBalance(accountId);

        return BalanceResponse.builder()
            .accountId(accountId)
            .balance(response.availableFunds())   // different field name
            .currency(response.currencyCode())
            .asOf(response.snapshotTime())
            .build();
    }
}

Without this layer, the monolith’s domain concepts leak into the new service and vice versa — you end up with a distributed monolith rather than a genuinely extracted service.

Database decomposition

The most challenging part of any extraction is the database. If the monolith shares a schema, the new service must either:

  1. Own its data — duplicate the relevant data into a new schema/store and keep both in sync during transition
  2. Share the table temporarily — the new service reads from the monolith’s table until migration completes

Option 2 is faster to implement but creates coupling. Option 1 is cleaner but requires dual-write during transition.

Dual-write in Spring Boot:

@Transactional
public void deposit(String accountId, BigDecimal amount) {
    // Write to legacy schema
    legacyAccountRepository.creditBalance(accountId, amount);

    // Write to new schema (new service's data store, same DB during transition)
    newAccountRepository.recordDeposit(accountId, amount, Instant.now());
}

Run dual-write long enough to verify the new service’s data against the legacy data. Once verified, the new service takes over writes and the legacy path is removed.

Traffic routing strategy

A practical three-phase approach:

Phase 1 — Shadow (0% live traffic to new service) Validate correctness and performance without risk. Run for 1–2 weeks minimum.

Phase 2 — Canary (5% → 25% → 50% live traffic) Increment rollout-percent in application config. Monitor error rates, latency, and divergences. Each increment holds for 48–72 hours before progressing.

Phase 3 — Full cutover (100%) All traffic routes to the new service. Legacy paths remain but handle 0% of traffic. Keep them for two weeks as a safety net before deletion.

Monitoring the migration

Track routing distribution and error rates split by service throughout:

@Around("execution(* com.example.facade.AccountFacadeController.*(..))")
public Object trackRouting(ProceedingJoinPoint pjp) throws Throwable {
    var route = flags.isNewAccountServiceEnabled(extractId(pjp))
        ? "new-service" : "legacy";

    return Metrics.timer("account.request.duration", "route", route)
        .record(() -> {
            try {
                return pjp.proceed();
            } catch (Throwable t) {
                Metrics.counter("account.request.error", "route", route).increment();
                throw t;
            }
        });
}

A Grafana dashboard showing request distribution, error rates, and P99 latency split by route gives you confidence to advance each rollout phase.

Testing the facade

The proxy controller is the most critical code during migration — routing bugs manifest only here. Write integration tests that exercise both paths:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@WireMockTest
class AccountFacadeTest {

    @Autowired
    private TestRestTemplate http;

    @Autowired
    private MigrationFeatureFlags flags;

    @Test
    void routesToLegacyWhenFlagDisabled() {
        flags.setGlobalEnabled(false);

        var response = http.getForEntity("/api/accounts/acc-1/balance", BalanceResponse.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody().source()).isEqualTo("legacy");
    }

    @Test
    void routesToNewServiceWhenFlagEnabled() {
        flags.enable("acc-1");

        stubFor(get("/customers/acc-1/balance")
            .willReturn(okJson("""{ "availableFunds": 250.00, "currencyCode": "GBP" }""")));

        var response = http.getForEntity("/api/accounts/acc-1/balance", BalanceResponse.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        verify(getRequestedFor(urlEqualTo("/customers/acc-1/balance")));
    }
}

What makes this work in practice

Feature flag granularity matters. Account-level routing prevents split-brain. Never route different operations for the same account to different services — reads and writes must agree on which system is authoritative.

The proxy is temporary. Every proxy route you add should have a planned deletion date. If you leave the facade in place indefinitely, you’ve added indirection without removing complexity — a distributed monolith with extra latency.

One active migration per team. Multiple simultaneous strangler figs with shared database decomposition compound each other’s risk. Finish one before starting another.

Start with a non-critical service. The strangler fig process itself has a learning curve — proxy wiring, dual-write patterns, shadow traffic infrastructure. Running it first on a non-critical boundary gives the team experience before tackling the riskiest extractions.

The Strangler Fig pattern is the closest thing to a guaranteed-safe migration path in large systems — it trades speed for risk reduction, which is almost always the right trade when the system you’re replacing is live with real users. If you’re working through a legacy migration and want a second opinion on approach, hire me.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 25+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.