| name | legacy-modernization |
| description | Legacy code modernization: Strangler Fig Pattern, Anti-Corruption Layer, Branch-by-Abstraction, Module Strangling, inkrementeller Rewrite vs. Big Bang (Entscheidungsframework), Seams für Testbarkeit, Database Migration Strategy (Dual-Write, Read Shadow). |
Legacy Modernization Skill
Every system becomes legacy. The question isn't whether to modernize — it's how to do it without breaking everything. This skill covers the patterns that let you replace legacy systems incrementally, safely, and in production.
When to Activate
- Planning a rewrite or major architectural migration
- A module is too risky to change without breaking things
- Legacy code lacks tests and has accumulated years of hidden behavior
- Choosing between "big bang rewrite" and incremental migration
- Designing a migration from a monolith to services, or from legacy framework to modern one
The Core Problem with Big Bang Rewrites
Netscape 2.0. The "next generation" platform that never shipped. The lesson: complete rewrites fail because:
- The legacy system accumulated behavior (bugs, edge cases, undocumented rules) over years
- The new system starts fresh and misses all of it
- At "done", the systems don't behave the same — and you can't tell which is correct
Rule: Never rewrite from scratch if the legacy system is mission-critical. Strangle it instead.
Strangler Fig Pattern
Inspired by the strangler fig tree that grows around a host tree until the host dies and the fig stands alone.
Concept
┌─────────────┐
Traffic → │ HTTP Proxy │ → (routes by feature flag or path)
└──────┬──────┘
│
┌─────────┴──────────┐
│ │
[NEW SERVICE] [LEGACY SYSTEM]
(modern, tested) (old, untested)
Implementation Steps
Step 1 — Install the proxy
Add an HTTP proxy or API gateway in front of the legacy system. Initially, all traffic passes through unchanged.
# Phase 1: all traffic to legacy
location / {
proxy_pass http://legacy-service;
}
Step 2 — Extract one capability
Choose the smallest, most isolated capability to migrate first. Never the most important one first.
# Phase 2: new service handles /api/v1/products, legacy handles everything else
location /api/v1/products {
proxy_pass http://new-product-service;
}
location / {
proxy_pass http://legacy-service;
}
Step 3 — Shadow Mode (low-risk validation)
Run both systems in parallel. Send traffic to new service, compare responses, serve legacy response to users.
async def shadow_request(request):
legacy_response = await legacy.handle(request)
try:
new_response = await new_service.handle(request)
compare_and_log(legacy_response, new_response)
except Exception as e:
log_comparison_failure(e)
return legacy_response
Step 4 — Canary (gradual traffic shift)
# Feature flag / percentage-based routing
upstream product_backend {
server new-product-service weight=10; # 10% new
server legacy-service weight=90; # 90% legacy
}
Step 5 — Full cutover + legacy deletion
Monitor for 2+ weeks after 100% cutover. Then delete the legacy code. Don't leave it around "just in case" — it becomes zombie code.
Anti-Corruption Layer (ACL)
When the legacy system has a different domain model (different names, concepts, structure), the ACL prevents the new system from inheriting the old model.
New System ACL Legacy System
────────── ─── ─────────────
Customer ←→ CustomerAdapter ←→ Client
Order ←→ OrderAdapter ←→ SalesTransaction
Product ←→ ProductAdapter ←→ SKU
Implementation
@Component
public class CustomerAdapter {
private final LegacyClientRepository legacyRepo;
public Customer findById(CustomerId id) {
LegacyClient legacyClient = legacyRepo.findByClientCode(id.value());
return Customer.builder()
.id(CustomerId.of(legacyClient.getClientCode()))
.name(legacyClient.getFullName())
.email(legacyClient.getEmailAddress().toLowerCase())
.build();
}
}
Key principle: The ACL translates concepts, not just data. It hides the legacy model from your new domain.
Branch-by-Abstraction
Use when a module needs to be replaced but can't be cut over via proxy (e.g., library code, not a service).
Steps
1. Extract interface
public class OrderService {
private LegacyPaymentGateway gateway;
public interface PaymentGateway {
PaymentResult charge(Money amount, PaymentMethod method);
void refund(String transactionId);
}
public class LegacyPaymentGatewayAdapter implements PaymentGateway { ... }
public class StripePaymentGateway implements PaymentGateway { ... }
2. Route via feature flag
@Bean
public PaymentGateway paymentGateway(FeatureFlags flags) {
return flags.isEnabled("stripe-gateway")
? new StripePaymentGateway(...)
: new LegacyPaymentGatewayAdapter(...);
}
3. Run both in shadow mode (optional)
4. Graduate to 100% new
5. Delete old implementation and the feature flag
Seams for Testability (Michael Feathers)
Legacy code is often untestable because everything is hardwired. A seam is a place where you can change behavior without editing existing code.
Object Seams (dependency injection)
public class OrderProcessor {
private Database db = new MySQLDatabase();
public class OrderProcessor {
private final Database db;
public OrderProcessor(Database db) { this.db = db; }
Extract and Override (subclass seam)
When you can't inject — temporarily override in tests:
class TestableOrderProcessor extends OrderProcessor {
@Override
protected void sendConfirmationEmail(Order order) {
}
}
Sprout Class
When a method is too tangled to test — grow a new testable class:
Incremental Rewrite vs. Big Bang — Decision Framework
| Factor | Favor Incremental | Favor Big Bang |
|---|
| System size | Large (>100k LOC) | Small (<10k LOC) |
| Business criticality | High (revenue critical) | Low (internal tool) |
| Test coverage | Low (<20%) | High (>80%) |
| Team knowledge | Incomplete (key people gone) | Complete |
| Technology constraint | Greenfield available | Same stack possible |
| Time pressure | Low | Very high |
| Stakeholder tolerance | Low risk tolerance | High risk tolerance |
Default: If in doubt, choose incremental. The risk asymmetry favors it strongly.
Module Strangling (Monolith to Modular)
Not just services — you can strangle within a monolith:
Phase 1: Extract interfaces for each domain module
Phase 2: Move implementations behind interfaces (with dependency injection)
Phase 3: Enforce module boundaries (ArchUnit, architecture-fitness functions)
Phase 4: Move each module to its own package or deployment unit
@AnalyzeClasses(packages = "com.myapp")
public class ArchitectureTest {
@ArchTest
static final ArchRule noOrderToUserDependency = noClasses()
.that().resideInPackage("com.myapp.order..")
.should().dependOnClassesThat()
.resideInPackage("com.myapp.user.impl..");
}
Database Migration Strategy
The hardest part of the Strangler Fig is the shared database.
Dual-Write
New system writes to both old and new schema. Old system reads from old schema. Backfill complete → old system reads from new.
def save_order(order):
legacy_db.save(to_legacy_format(order))
new_db.save(order)
Read Shadow
New system reads from new DB but compares with legacy reads:
def get_customer(id):
new = new_db.find_customer(id)
legacy = legacy_db.find_client(id)
if not matches(new, legacy):
log_discrepancy(id, new, legacy)
return new
Expand-Contract Pattern
- Expand: Add new column (nullable), backfill
- Migrate: Switch reads to new column
- Contract: Remove old column
Reference Skills
technical-debt — quantifying and prioritizing what to modernize
resilience-patterns — adding circuit breakers during migration to protect new services
/modernize command — step-by-step modernization plan for a specific component
/debt-audit command — inventory technical debt before deciding what to modernize