Skip to main content
the readable codebase

The Strangler Pattern for Services: Replacing the God Class One Method at a Time

4 min read Chapter 26 of 27

The Strangler Pattern for Services: Replacing the God Class One Method at a Time

The Smell

A tech lead proposes: “Let’s rewrite ShipmentService from scratch.” The team spends two weeks building the new version. During those two weeks, four bug fixes and two features are added to the old ShipmentService by other developers. The new version is now out of date. The team spends another week reconciling. During that week, two more changes land on the old version. The rewrite is never completed. Both versions exist in the codebase for eight months until someone deletes the new version because nobody remembers what it was for.

Rewrites fail because they compete with ongoing development. The old code keeps changing while the new code is being built. The merge conflict grows faster than the rewrite progresses.

The Cognitive Cost

A codebase with two versions of the same service doubles the cognitive load for any developer touching the affected area. “Which ShipmentService should I use? The old one in the service package or the new one in the shipment package?” The two versions drift apart. Some callers use the old one. Some callers use the new one. Nobody knows which is canonical.

The strangler pattern avoids this by extracting methods one at a time. At every step, there is one canonical version. Old callers are migrated incrementally. The old class shrinks. The new class grows. The old class is deleted when it is empty.

The Before

// HARD TO READ: ShipmentService with 47 methods. A rewrite would take weeks
// and compete with ongoing development.

@Service
public class ShipmentService {

    // 23 dependencies...

    public ShipmentRate calculateRate(Long shipmentId, Long carrierId) {
        // 260 lines of rate calculation
    }

    public void updateStatus(Long shipmentId, ShipmentStatus newStatus) {
        // 180 lines of status update logic
    }

    public byte[] generateLabel(Long shipmentId, LabelFormat format) {
        // 270 lines of label generation
    }

    // ... 44 more methods
}

// All 14 callers of calculateRate:
// ShipmentController.java: shipmentService.calculateRate(id, carrierId);
// QuoteController.java:    shipmentService.calculateRate(id, carrierId);
// BulkQuoteService.java:   shipmentService.calculateRate(id, carrierId);
// ... 11 more callers

The Fix

Step 1: Extract the new class with the method implementation.

// READABLE: New focused class with the extracted logic.

@Service
public class ShipmentRateCalculator {

    private final ShipmentRepository shipments;
    private final CarrierRateRepository carrierRates;
    private final TaxCalculator taxes;
    private final CurrencyConverter currencies;

    public ShippingQuote calculateQuote(ShipmentId shipmentId, CarrierId carrierId) {
        // Rate calculation logic moved here
    }
}

Step 2: Delegate from the old class. Do not delete the old method yet.

// READABLE: Old method delegates to new class.
// All 14 callers still work. No migration required yet.

@Service
public class ShipmentService {

    private final ShipmentRateCalculator rateCalculator; // New dependency

    @Deprecated // Marks this as the old path
    public ShipmentRate calculateRate(Long shipmentId, Long carrierId) {
        // Delegate to the new class
        ShippingQuote quote = rateCalculator.calculateQuote(
            new ShipmentId(shipmentId), new CarrierId(carrierId));
        // Convert to old return type if needed for backward compatibility
        return ShipmentRate.fromQuote(quote);
    }
}

Step 3: Migrate callers one at a time, in separate pull requests.

// Before migration:
ShipmentRate rate = shipmentService.calculateRate(id, carrierId);

// After migration:
ShippingQuote quote = rateCalculator.calculateQuote(
    new ShipmentId(id), new CarrierId(carrierId));

Step 4: When all callers are migrated, delete the delegating method from ShipmentService.

Each step is a separate pull request. Each pull request is independently reviewable. Each pull request leaves the codebase in a working state. If the migration stalls after migrating 10 of 14 callers, the codebase still works. The remaining 4 callers use the delegation path. There is no half-finished rewrite. There is no merge conflict with ongoing development.

After extracting all five responsibility clusters, ShipmentService has zero methods left. Delete the class. The God class is gone. No feature freeze was required. No rewrite was attempted. The class was strangled, one method at a time.

The Rule

Never rewrite a God class in a parallel branch. Extract one responsibility cluster per pull request using delegation: create the new class, delegate from the old method, migrate callers, delete the old method. Each step is safe, reversible, and independently deployable. If the strangling stalls halfway, the codebase is still better than when it started: the extracted responsibilities are in focused classes, and the remaining responsibilities are in a smaller God class. A stalled strangling is progress. A stalled rewrite is waste.