Refactoring a small codebase is straightforward. Refactoring a large one—hundreds of thousands of lines, dozens of contributors, continuous deployments—requires strategy. Here’s what works.

The Strangler Fig Pattern

Don’t replace systems wholesale. Strangle them gradually.

The strangler fig grows around a host tree, eventually replacing it entirely while the tree continues to live. Apply this to code:

  1. Identify a seam in the existing system
  2. Build new functionality alongside the old
  3. Route traffic to the new implementation
  4. Remove the old code once traffic is migrated
# Before: direct call to legacy system
def get_user(user_id):
    return legacy_user_service.fetch(user_id)

# During migration: feature flag controls routing
def get_user(user_id):
    if feature_flags.new_user_service_enabled(user_id):
        return new_user_service.get(user_id)
    return legacy_user_service.fetch(user_id)

# After: legacy code removed
def get_user(user_id):
    return new_user_service.get(user_id)

This pattern lets you migrate incrementally, with easy rollback at every step.

The Branch by Abstraction Pattern

When you can’t easily switch between implementations, create an abstraction layer first:

Step 1: Introduce an interface

class UserRepository(Protocol):
    def get(self, user_id: str) -> User: ...
    def save(self, user: User) -> None: ...

Step 2: Wrap the old implementation

class LegacyUserRepository:
    def get(self, user_id: str) -> User:
        # Old messy code, unchanged
        raw = self._old_db.query(f"SELECT * FROM users WHERE id = {user_id}")
        return self._convert(raw)

Step 3: Build the new implementation

class ModernUserRepository:
    def get(self, user_id: str) -> User:
        # Clean, tested, modern code
        return self.session.query(User).filter_by(id=user_id).first()

Step 4: Swap implementations

# Change injection, not call sites
container.register(UserRepository, ModernUserRepository)

The abstraction decouples migration from callers, letting you change the implementation without touching every file that uses it.

The Mikado Method

For complex refactorings with cascading dependencies, use the Mikado method:

  1. Try the change you want to make
  2. When it fails, note the prerequisite
  3. Revert your change
  4. Recursively apply the method to the prerequisite
  5. Once prerequisites are done, make the original change

This builds a dependency graph:

Goal: Extract PaymentService to separate module
├── Prerequisite: Remove circular dependency with UserService
│   ├── Prerequisite: Move shared types to common module
│   └── Prerequisite: Inject UserRepository instead of importing
├── Prerequisite: Create stable interface for PaymentService
└── Prerequisite: Add integration tests for payment flows

Work bottom-up, committing each small change. If you get interrupted, the graph shows exactly where you left off.

Parallel Change (Expand and Contract)

When changing interfaces that have many callers:

Expand: Add the new interface alongside the old

class UserService:
    # Old method - deprecated but still works
    def get_user(self, user_id: str) -> dict:
        warnings.warn("Use get_user_v2 instead", DeprecationWarning)
        return self.get_user_v2(user_id).to_dict()

    # New method - the target interface
    def get_user_v2(self, user_id: str) -> User:
        return self.repository.get(user_id)

Migrate: Update callers one at a time

# Before
user_data = user_service.get_user(user_id)
name = user_data['name']

# After
user = user_service.get_user_v2(user_id)
name = user.name

Contract: Remove the old interface once all callers are migrated

class UserService:
    def get_user(self, user_id: str) -> User:  # Renamed from v2
        return self.repository.get(user_id)

This lets you migrate incrementally without coordinating a big-bang release.

Incremental Type Introduction

In dynamically typed codebases, add types incrementally to improve safety:

Start with boundaries:

# Type the public interface first
def process_order(order_id: str) -> OrderResult:
    ...

Add internal types as you refactor:

def process_order(order_id: str) -> OrderResult:
    order: Order = fetch_order(order_id)  # Add type
    ...

Use type checkers to find issues:

mypy --strict src/orders/

Types catch bugs and serve as documentation. Adding them incrementally makes the codebase safer over time without requiring a massive migration.

The Walking Skeleton

When adding a new architectural pattern, build a minimal end-to-end implementation first:

Want to introduce event sourcing? Build one aggregate with events, projections, and replay—the thinnest possible slice. Get it working in production.

This skeleton validates your approach before you invest heavily. It also serves as a template for migrating other components.

Feature Flags for Safety

Wrap refactoring changes in feature flags to control risk:

def calculate_price(product, quantity):
    if feature_flags.is_enabled('new_pricing_engine'):
        return new_pricing_engine.calculate(product, quantity)
    return legacy_calculate_price(product, quantity)

Benefits:

  • Roll out to a percentage of traffic
  • Immediately roll back if issues arise
  • A/B test old vs. new behavior
  • Deploy code before enabling it

Don’t forget cleanup: Feature flags are technical debt. Remove them once migration is complete.

Automated Codemods

For mechanical refactorings, write scripts instead of editing by hand:

# Using libcst for Python
import libcst as cst

class RenameMethod(cst.CSTTransformer):
    def leave_Call(self, original, updated):
        if self.is_target_method(original):
            return updated.with_changes(
                func=cst.Attribute(
                    value=updated.func.value,
                    attr=cst.Name("new_method_name")
                )
            )
        return updated

# Apply across entire codebase
for file in find_python_files():
    transform(file, RenameMethod())

Codemods are:

  • Faster than manual editing
  • More consistent (no forgotten call sites)
  • Reviewable (the transformation itself can be reviewed)

Measuring Progress

Track your refactoring efforts:

Code metrics:

  • Cyclomatic complexity
  • Test coverage
  • Module coupling
  • File sizes

Development metrics:

  • Time to implement similar features (before/after)
  • Incident rates in refactored areas
  • Developer satisfaction surveys

Visualize:

# Track complexity over time
radon cc src/ --average --total-average >> metrics.log

Data helps justify continued investment and shows when you’re done.

The Most Important Strategy

Ship continuously. Every refactoring step should be deployable. If you can’t deploy after each commit, you’re not refactoring—you’re rewriting.

Small, incremental changes are:

  • Easier to review
  • Easier to roll back
  • Less risky
  • Less likely to conflict with ongoing feature work

The best refactoring is invisible. Teams keep shipping features while the codebase quietly improves underneath. That’s the goal.