Every engineering team eventually faces the same daunting question: what do we do with the legacy monolith? The temptation is always a complete rewrite — tear it all down and build something clean from scratch. But big-bang rewrites have a notorious failure rate. They take longer than expected, carry enormous risk, and block feature delivery for months or years while the new system catches up to the old one.
The Strangler Fig Pattern offers a safer alternative. Instead of replacing the old system in one heroic effort, you gradually migrate functionality piece by piece, routing traffic from the legacy system to the new one as each piece is ready. The old system shrinks — strangled — until there’s nothing left to migrate, and you can decommission it entirely.
Let’s walk through how this pattern works in practice, with a Go implementation you can adapt for your own migration.
How the Pattern Works
The pattern follows three phases:
- Facade: Place a routing layer (typically an API gateway or reverse proxy) in front of the legacy system. All client traffic flows through this facade. Initially, it simply forwards everything to the legacy backend.
- Incremental migration: Build new functionality in the new system, then update the facade to route specific endpoints to the new backend instead of the legacy one. Each migration is a small, independently deployable change.
- Decommission: Once all traffic flows to the new system, remove the legacy backend entirely.
The beauty of this approach is that each step is reversible. If a migrated endpoint has problems in production, you flip the route back to the legacy system. No rollback of months of work — just a configuration change.
Building the Facade in Go
The facade is the heart of the pattern. It needs to route requests to the correct backend based on configurable rules. Here’s a practical implementation using Go’s standard library and a reverse proxy:
package main
import (
"log"
"net/http"
"net/http/httputil"
"net/url"
"strings"
)
// RouteConfig maps URL path prefixes to their target backend.
// Migrated paths go to the new service; everything else defaults
// to the legacy system.
type RouteConfig struct {
Routes map[string]string // path prefix -> backend URL
}
type StranglerProxy struct {
legacyURL *url.URL
modernURL *url.URL
config RouteConfig
legacyProxy *httputil.ReverseProxy
modernProxy *httputil.ReverseProxy
}
func NewStranglerProxy(legacyTarget, modernTarget string, config RouteConfig) (*StranglerProxy, error) {
legacyURL, err := url.Parse(legacyTarget)
if err != nil {
return nil, err
}
modernURL, err := url.Parse(modernTarget)
if err != nil {
return nil, err
}
return &StranglerProxy{
legacyURL: legacyURL,
modernURL: modernURL,
config: config,
legacyProxy: httputil.NewSingleHostReverseProxy(legacyURL),
modernProxy: httputil.NewSingleHostReverseProxy(modernURL),
}, nil
}
func (sp *StranglerProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
backend := sp.routeFor(r.URL.Path)
if backend == "modern" {
log.Printf("routing %s %s -> modern", r.Method, r.URL.Path)
sp.modernProxy.ServeHTTP(w, r)
return
}
log.Printf("routing %s %s -> legacy", r.Method, r.URL.Path)
sp.legacyProxy.ServeHTTP(w, r)
}
// routeFor checks if a path has been migrated to the new system.
func (sp *StranglerProxy) routeFor(path string) string {
for prefix, backend := range sp.config.Routes {
if strings.HasPrefix(path, prefix) {
return backend
}
}
return "legacy"
}
func main() {
config := RouteConfig{
Routes: map[string]string{
"/api/v2/users": "modern", // migrated
"/api/v2/orders": "modern", // migrated
// /api/v1/* and everything else -> legacy
},
}
proxy, err := NewStranglerProxy(
"http://legacy-service:8080",
"http://modern-service:8081",
config,
)
if err != nil {
log.Fatal(err)
}
log.Println("strangler facade listening on :8090")
log.Fatal(http.ListenAndServe(":8090", proxy))
}
This facade acts as a transparent proxy. Clients don’t know — and don’t need to know — whether their request is handled by the legacy or modern backend. The routing table is the migration plan: every entry in the Routes map is a piece of functionality that has been successfully migrated.
Adding Feature Flags for Safe Rollout
Routing by path prefix is the simplest approach, but sometimes you need more granular control. A common strategy is to migrate a percentage of traffic to the new system and monitor for errors before going all-in. This is where a feature flag mechanism layered on top of the facade helps:
import (
"crypto/sha1"
"encoding/hex"
"math/big"
)
type CanaryConfig struct {
// MigratedPaths maps path prefix to the percentage of traffic
// routed to the new system (0-100).
MigratedPaths map[string]int
}
type CanaryProxy struct {
legacyProxy *httputil.ReverseProxy
modernProxy *httputil.ReverseProxy
config CanaryConfig
}
func (cp *CanaryProxy) routeFor(path, userID string) string {
for prefix, percentage := range cp.config.MigratedPaths {
if !strings.HasPrefix(path, prefix) {
continue
}
if percentage >= 100 {
return "modern"
}
if cp.hashToPercent(userID) < percentage {
return "modern"
}
}
return "legacy"
}
// hashToPercent produces a stable 0-99 value from a string,
// so the same user always gets the same backend.
func (cp *CanaryProxy) hashToPercent(key string) int {
h := sha1.Sum([]byte(key))
n := new(big.Int).SetBytes(h[:4])
return int(n.Mod(n, big.NewInt(100)).Int64())
}
By hashing a user identifier, you ensure consistent routing — a given user always hits the same backend, which avoids confusing mid-session switches between old and new systems. Start with 5% of traffic on a migrated endpoint, watch your error rates and latency metrics, and ramp up to 100% when you're confident.
Handling Data Migration
The facade handles request routing, but data migration is often the hardest part. Both systems need to read and write the same data during the transition. Three common strategies:
- Shared database: Both old and new services connect to the same database. The new service gradually takes ownership of tables or schemas as endpoints migrate. Simplest to implement, but tightly couples the systems.
- Change Data Capture (CDC): Use a tool like Debezium to stream changes from the legacy database to the new one. The new system builds its own data models while staying in sync. This decouples storage but adds infrastructure complexity.
- Event-driven sync: The legacy system publishes domain events whenever its state changes. The new system consumes these events to build its own read models. This aligns well if you're already moving toward event-driven architecture.
For most migrations, shared database is the pragmatic starting point. As you migrate more endpoints and the new system matures, you can introduce CDC or event-driven sync to eventually decouple the data stores entirely.
Common Pitfalls
- Skipping the facade: If clients call the legacy system directly, you can't intercept and redirect traffic. The facade must be non-negotiable before migration begins.
- Migrating too much at once: The whole point is incremental progress. If you migrate an entire subsystem in one shot, you lose the safety of incremental rollback. Migrate one endpoint at a time.
- Ignoring session state: If a user's session spans endpoints that are split between old and new systems, you can get inconsistent behavior. Use sticky routing (same user → same backend) during the transition.
- Never decommissioning: The pattern is called "strangler" for a reason. Once all traffic flows to the new system, actually turn off the legacy system. Teams often leave it running "just in case" indefinitely, negating the maintenance savings.
- Mismatched API contracts: The facade should present a unified API. If the new system has different response formats, transform them in the facade so clients don't need to handle both formats.
When Not to Use the Strangler Fig
The pattern isn't universally applicable. If the legacy system is fundamentally broken — data corruption, security vulnerabilities, or performance so bad it's costing customers — a faster migration may be necessary. Similarly, if the system is small enough that a full rewrite takes weeks rather than months, the overhead of a facade may not be worth it.
The Strangler Fig Pattern shines for large, business-critical systems where downtime is unacceptable and the risk of a complete rewrite is too high. It lets you modernize while still shipping features, test each migration in production with real traffic, and maintain the ability to roll back at any point. That combination of safety and momentum is what makes it one of the most practical patterns for legacy modernization.
If you're staring down a monolith rewrite, start with the facade. Get traffic flowing through it transparently. Then pick the smallest, most self-contained endpoint and migrate it. That first successful migration builds the confidence and muscle memory to tackle the rest.