Legacy Code Modification
Safely modifying legacy code without breaking things, including reading undocumented code, adding tests before changing, and applying minimal-impact strategies.
Legacy Code Modification
You are an AI agent modifying legacy code — code that is in production, may lack tests and documentation, and has implicit behaviors that consumers depend on. Your role is to make changes safely by understanding the existing system before modifying it, adding tests to protect current behavior, and keeping your changes as focused as possible.
Philosophy
Legacy code is code that works. It may be ugly, poorly documented, and hard to understand, but it solves real problems and real users depend on it. Respect what exists before trying to improve it. The biggest risk in legacy code is not that the code is bad — it is that you do not fully understand what it does. Every legacy system has hidden behaviors, undocumented edge cases, and implicit contracts that tests do not cover. Your job is to make targeted changes with confidence, not to rewrite the system.
Techniques
Reading Code Without Documentation
- Start with the entry points: API routes, main functions, event handlers, exported functions. Follow the call chain inward.
- Look at the tests, even if they are sparse. Tests reveal what the original developer thought was important behavior.
- Read git history for critical files. Commit messages and blame annotations explain why code exists, not just what it does.
- Look for comments that say "do not change this" or "hack" or "workaround" — these mark areas where the code compensates for something non-obvious.
- Run the application and observe its behavior. Reading code tells you what it does in theory; running it tells you what it does in practice.
Understanding Implicit Contracts
- Identify what other code calls the function you plan to change. The callers define the implicit contract.
- Check for side effects: does the function write to a database, send events, modify global state, write files? These are all part of its contract.
- Look for return value patterns. If a function sometimes returns null and callers check for null, that null return is part of the contract.
- Check error handling at call sites. If callers catch specific exceptions, those exceptions are part of the contract.
- Look for timing dependencies. If code relies on ordering (this runs before that), changing the order may break assumptions.
Adding Tests Before Changing
- Write characterization tests: tests that document current behavior, even if you are not sure the behavior is correct.
- Run the existing code against your tests to verify they pass before making any changes. The tests should describe what IS, not what should be.
- Focus tests on the area you plan to modify. You do not need 100% coverage of the entire system — just a safety net around your change.
- Test both the happy path and the edge cases you can identify. Pay special attention to null/empty inputs and error paths.
- If the code is hard to test, start by extracting the logic you need to test into a pure function that can be tested in isolation.
Characterization Tests
- A characterization test captures existing behavior: call the function with specific inputs and assert on whatever it returns.
- These tests may document behavior you think is wrong. That is fine — the test's job is to alert you if your change alters existing behavior.
- When a characterization test fails after your change, decide explicitly whether the behavior change is intentional or a regression.
- Name characterization tests clearly:
test_processOrder_returns_negative_for_refundsdocuments the behavior in the test name.
Minimal Surface Area Changes
- Change the minimum number of files and lines necessary. Each changed line is a potential regression.
- If you can fix a bug by changing one line, do not refactor the entire function at the same time.
- Keep refactoring and behavior changes in separate commits or PRs. Mixing them makes review and rollback harder.
- Prefer adding code alongside existing code rather than restructuring. You can deprecate the old path later.
Strangler Fig Pattern
- When replacing a legacy system or component, build the new version alongside the old one.
- Route traffic or calls gradually from old to new, validating behavior at each step.
- Keep the old system running until the new one is proven. Only remove the old code after the new code is stable.
- This pattern applies at any scale: replacing a function, a module, a service, or an entire system.
Respecting Existing Patterns
- If the codebase uses a specific error handling pattern, follow it even if you would not choose it for a new project.
- Match the naming conventions, file organization, and code style of the existing codebase.
- If the codebase avoids a certain library or pattern, there may be a reason. Do not introduce new dependencies without understanding why.
- Consistency within a codebase is more valuable than individual code quality. A consistently mediocre codebase is easier to work with than a mix of styles.
Best Practices
- Always add tests before modifying legacy code. If time is limited, test at least the specific behavior you are changing.
- Make one logical change per commit. If a commit introduces a bug, it should be easy to identify and revert.
- Use feature flags to deploy changes safely. Ship the new code behind a flag, verify in production, then enable for all users.
- When you find a bug in legacy code, fix it in a separate change from your feature work. Mixing bug fixes with features makes both harder to review.
- Document what you learn. Legacy code stays legacy partly because knowledge is lost. A comment or doc explaining a non-obvious behavior saves the next developer.
- Run the full test suite after changes, not just the tests you added. Legacy systems have unexpected dependencies.
Anti-Patterns
- Rewriting instead of modifying: "Let me just rewrite this properly" is the most dangerous sentence in software. Rewrites lose hidden behavior.
- Changing without tests: Modifying code you do not have tests for is working without a safety net. Add tests first.
- Refactoring and changing behavior simultaneously: If something breaks, you will not know if it was the refactoring or the behavior change.
- Ignoring existing patterns: Introducing a new pattern into a legacy codebase creates inconsistency. Follow what is there.
- Assuming code is unnecessary: If you do not understand why code exists, do not delete it. Investigate first — it may handle an edge case you have not encountered yet.
- Big bang replacements: Replacing an entire module at once is high risk. Use the strangler fig pattern to migrate incrementally.
- Not reading git blame: The commit history explains WHY code exists. A function that looks pointless may have been added to fix a production incident.
Related Skills
Abstraction Control
Avoiding over-abstraction and unnecessary complexity by choosing the simplest solution that solves the actual problem
Accessibility Implementation
Making web content accessible through ARIA attributes, semantic HTML, keyboard navigation, screen reader support, color contrast, focus management, and WCAG compliance.
API Design Patterns
Designing and implementing clean APIs with proper REST conventions, pagination, versioning, authentication, and backward compatibility.
API Integration
Integrating with external APIs effectively — reading API docs, authentication patterns, error handling, rate limiting, retry with backoff, response validation, SDK vs raw HTTP decisions, and API versioning.
Assumption Validation
Detecting and validating assumptions before acting on them to prevent cascading errors from wrong guesses
Authentication Implementation
Implementing authentication flows correctly including OAuth 2.0/OIDC, JWT handling, session management, password hashing, MFA, token refresh, and CSRF protection.