A new feature request lands, and you realize it has to touch the old permissions module. The project planning meeting suddenly gets very quiet because everyone knows any change in that part of the legacy code means weeks of careful testing, unpredictable behavior, and a high-stakes deployment.

This is the friction point where building new things slows to a crawl, constrained by decisions made years ago by people who are no longer on the team.
Beyond the “Rewrite or Refactor” Dichotomy
In these moments, someone will almost always suggest a full rewrite. It’s an appealing idea, a clean slate where all past mistakes are erased. The reality is that a full rewrite is rarely a practical solution. It freezes the delivery of new business value for months or even years, introduces a massive amount of risk, and discards years of battle-tested logic that, for all its faults, currently runs the business. The code might be hard to read, but it contains valuable, implicit knowledge about edge cases you haven’t even thought of yet.
The alternative, incremental refactoring, works much better when treated as a continuous process instead of a standalone project. The goal is to evolve the system, not to achieve a perfect, idealized state. The most valuable code is often the oldest, and learning to work with it is a core engineering skill.
When legacy code becomes a real bottleneck
The term “technical debt” can be a bit abstract, but a bottleneck is painfully concrete. It’s the module that slows down every new feature. It’s the source of recurring performance incidents or the component with a known security vulnerability that’s too entangled to patch easily. You know you’ve found a bottleneck when multiple teams have to coordinate for even minor changes, or when the operational overhead of keeping a service running outweighs the value it delivers.
These are the areas that actively impede progress. Identifying them is the first step, because you can’t fix everything at once, and trying to do so just leads to paralysis.
An Approach for Evolving Systems
The most effective approach is to stop thinking about “fixing everything” and instead focus on “strategically evolving” the system. Your job is to create pathways for new development while safely containing the old. Architectural patterns like the Strangler Fig are built on this idea: you gradually intercept traffic to an old system, route it to new services, and eventually, the old system is “strangled” out of existence without a high-risk cutover.
This requires prioritizing changes based on a combination of business impact and technical risk. A part of the codebase might be messy, but if it’s stable and rarely changes, it’s probably not where you should spend your time. Focus on the parts of the system that are both critical and under constant pressure to change.
Techniques for Managing the Boundaries
To evolve a system safely, you need to manage the boundaries between the old and new parts of your codebase. Here are a few practical techniques that work well:
- Identify Seams and Interfaces: First, you have to find the natural joints in the system. These are the points where you can intercept calls between components without rewriting either one. A seam could be an API call, a method invocation, or a message being passed to a queue. Once you find them, you can start to redirect behavior.
- Isolate Functionality: When you have a particularly problematic area, the goal is to contain it. You can write an adapter or an anti-corruption layer that sits between the old code and the rest of the application. This new layer translates requests and responses, hiding the complexity of the legacy component and providing a clean, modern interface for new code to interact with.
- Write Characterization Tests: You can’t safely change code if you don’t know what it does. Before you touch anything, write a suite of tests that document the current behavior, including its bugs. These “characterization tests” don’t judge the code; they just capture its existing state. When you make a change, you can run these tests to ensure you haven’t broken an implicit assumption somewhere else in the system.
- Implement Observability: You need to see what the old components are doing in production. Adding detailed logging, metrics, and distributed tracing gives you the insight needed to understand runtime behavior. Without this, you’re flying blind every time you deploy a change that touches the older code.
The Incremental Evolution Framework: Decide, Delimit, Develop
1. Decide: Where is the real pain?
Your resources are finite, so prioritization is everything. The place to start is at the intersection of business value and frequency of change. Which part of the system is most often a blocker for important projects? Evaluate the impact of modernizing a component versus the effort required. Often, the highest-leverage targets are areas with high coupling or poor testability, because improving them unlocks velocity for multiple teams.
2. Delimit: How can we contain the change?
Once you’ve decided where to focus, the next step is to draw a boundary around the problem area. This is where architectural decisions come in. You might create a new microservice to house the new logic, using an API gateway or a message queue to bridge the gap between the old and new systems. The key is to create a clear interface that abstracts away the legacy implementation. The rest of the application shouldn’t need to know whether it’s talking to a 10-year-old monolith or a brand-new service.
3. Develop: Execute with small, well-tested iterations
With a clear boundary in place, you can start developing. New features that interact with the legacy code should be built using test-driven development to ensure the new logic and the integrations are correct. Small, incremental changes deployed via an automated pipeline give you the confidence to move quickly. Each pull request should be a small, verifiable step forward. Code reviews for this kind of work should be especially focused on the clarity and durability of the new interfaces you’re creating.
Cultivating a Sustainable Relationship with Your Codebase
Working with legacy systems isn’t a one-off project with a defined end. It’s a continuous process of improvement. The most effective teams build a culture that values this incremental work, not just reactive fixes when something breaks. This means investing in developer tools and practices that support safe, small changes and make refactoring a low-ceremony activity.
Ultimately, “modern” is a moving target. The shiny new service you build today will be somebody else’s legacy code in five years. The real skill is learning to manage complexity as an ongoing practice, ensuring the codebase you have is one that allows you to keep building, testing, and delivering value.