Working in a Legacy Codebase: The Reality No One Teaches You


You open the file. It’s 2,400 lines. The function at the top is called handleStuff. There are no tests. The last commit is from 2019 and says “fix bug”. The person who wrote it left three years ago. This is the payment processing module. You have a ticket to add a feature.

Most developers are hired into existing codebases. Most production software is legacy software - it just hasn’t been called that yet. Working in a legacy codebase is one of the most common engineering skills and one of the least taught.

What Legacy Actually Means

“Legacy” doesn’t mean old, and it doesn’t mean bad. It means: code that works in production and that engineers are afraid to change.

The fear is the defining property. A well-maintained codebase from 2015 with good test coverage isn’t legacy in the meaningful sense. A six-month-old codebase with no tests and a bus factor of one is.

The fear comes from uncertainty: you don’t know what the code does in all cases, you don’t know what depends on it, and you don’t have the safety net to find out by experiment. Change something and you don’t know what else breaks.

The job, then, is to reduce uncertainty before changing things.

Before You Touch Anything

Read before you write. Not the ticket - the code. The ticket describes intent. The code describes reality.

Trace the data through the function you’re modifying: where does it come from, what transforms it, what downstream code uses it, what assumptions does it make? An hour of reading is worth a day of debugging a change that had unexpected effects.

Use git as archaeology:

# What changed recently in this file?
git log --oneline src/payments/processor.py

# Why was this specific line written?
git log -S "that_mysterious_check" --follow -p src/payments/processor.py

# When did this behavior change and why?
git blame src/payments/processor.py

The commit messages won’t always explain. But the diff often does: “this check was added after this if statement” implies a reason even when the commit says “fix”.

Write characterization tests before changing anything:

def test_existing_behavior():
    # Document what the code currently does, not what it should do
    result = payment_processor.process({
        'amount': 100,
        'currency': 'USD',
        'method': 'card',
    })
    assert result['status'] == 'success'
    assert result['transaction_id'] is not None

These tests don’t verify correctness - they verify that you haven’t accidentally changed existing behavior. If a characterization test fails after your change, you’ve changed something you didn’t intend to.

The Strangler Fig Pattern

Don’t rewrite the module. Add a new one alongside it.

Gradually redirect calls to the new module. Once nothing calls the old module, delete it. This lets you build new behavior incrementally, with tests, without touching the old code until you understand it.

# Old: everything goes through process_payment()
def handle_checkout(order):
    return process_payment(order)  # the 400-line function

# Transition: new path for new transaction types
def handle_checkout(order):
    if order.requires_new_flow():
        return new_payment_handler.process(order)  # new, tested
    return process_payment(order)  # old, unchanged

# Eventually: all paths go through the new handler, old is deleted

The key discipline: don’t touch the old code while building the new code. The old code works. The new code is where your change happens. You merge them gradually as confidence grows.

What Not to Do

The big rewrite. “This code is a mess, we should rewrite it properly” is almost always wrong. The existing code, however messy, works in production. It has years of implicit business logic encoded in patches: that inexplicable null check that was added after an edge case caused a production incident, the special handling for user IDs above a certain threshold, the retry logic that was calibrated to a specific third-party API’s behavior. A rewrite loses all of this and rediscovers it through new incidents.

The estimated time for a rewrite is always wrong. The new code will be cleaner on the happy path and messier in all the edge cases the original code accumulated.

Deleting code that looks unused. Production systems have code paths that run under specific conditions: at month-end, during high load, for specific user segments, for legacy data created years ago. grep -r "functionName" might find no callers in the codebase, but the function might be called by a cron job, a webhook receiver, a migration script, or through reflection.

# Check everything, not just the main codebase
grep -r "handleStuff" .
grep -r "handleStuff" ../infrastructure-scripts/
grep -r "handleStuff" ../cron-jobs/

When in doubt, deprecate with a log statement before deleting. If the log never fires in production after two weeks, it’s safe to remove.

Fixing everything at once. You’re there to add a feature. You’ll notice ten other things that should be fixed. Note them, don’t fix them. “Leave the campsite cleaner than you found it” is good advice - but “cleaner” means slightly better, not renovated. Each additional change is additional risk.

The Seam Technique

Legacy code is hard to test because everything is coupled. The handleStuff function queries the database, calls an external API, sends an email, and updates three other tables. Testing any one behavior requires all the others to work.

The seam technique: find the places in the code where you can introduce a dependency without modifying the existing logic.

# Before: coupled, untestable
class PaymentProcessor:
    def process(self, order):
        # Hardcoded dependency on EmailService
        EmailService.send_confirmation(order.user_email)
        return self._charge(order)

# After: seam introduced via constructor
class PaymentProcessor:
    def __init__(self, email_service=None):
        self.email_service = email_service or EmailService()

    def process(self, order):
        self.email_service.send_confirmation(order.user_email)
        return self._charge(order)

# Now testable
def test_payment_processing():
    mock_email = MockEmailService()
    processor = PaymentProcessor(email_service=mock_email)
    result = processor.process(test_order)
    assert result['status'] == 'success'

You didn’t change the behavior. You added a seam where none existed. Now you can test the payment logic without triggering real emails.

The Mental Model That Helps

You’re an archaeologist, not an architect. The code tells a story. Each oddly specific check, each comment that says “don’t remove this”, each mysterious variable name is a fossil - evidence of a production incident, a business rule, or a constraint that was true once and might still be.

Your job is to understand the story before you add a new chapter. The engineer who wrote if user_id > 50000 and not user.legacy_flag had a reason. You don’t know the reason, but assuming there wasn’t one is how you reintroduce the bug they fixed.

Work with the existing structure rather than against it. Make the smallest change that satisfies the ticket. Leave it slightly better than you found it - one test added, one function extracted, one variable renamed - not dramatically better. The goal is to move the codebase forward while maintaining the trust of the team that’s been working in it.



Read more