Working with Established Systems
Introduction
The majority of your career as a software engineer will be spent working on existing systems. These systems have already been built, and you are responsible for maintaining and extending them. You are much more constrained than when you are working on a fresh project. Your changes have to fit within the existing architecture, and you have to be careful not to break existing functionality.
Sometimes you'll get a chance to refactor part of the main structure of the system, but most of the time you'll be working within the existing constraints. This can be frustrating, but it is also an opportunity to learn how to work with legacy code and how to make incremental improvements.
The term Legacy System is not well defined. Whether or not a system is considered legacy is subjective and depends on who you ask. The connotation can also vary and isn't always negative. Some developers use it to describe any system that is old, while others use it to describe systems that are difficult to work with. I've even heard it used to describe any system not built by the speaker.
I've chosen to use Existing Systems instead of Legacy Systems because I believe it is a more neutral term. I prefer to use Legacy to describe systems that you are afraid to change, for whatever reason. This could be because the system is poorly documented, or because the original developers are no longer around, or just because it has no tests. No matter what the reason, if changes can't be made safely, the system is a legacy system. A system under active development rarely feels like a legacy system.
The Role of Strategic Thinking
Design remains a critical part of software development in mature systems, but the nature of that design work changes. In well-established codebases, strategic thinking becomes even more important than it is in greenfield projects. These systems have often accumulated complexity over years or even decades. They are large, interdependent, and fragile in places. Your goal is not just to add functionality, but to do so without degrading the existing structure.
Unlike greenfield projects, where small missteps are relatively low-cost, changes in established systems can have wide-reaching consequences. There is less tolerance for incidental complexity. What might seem like a harmless shortcut can become a long-term liability. Therefore, working in a legacy environment demands a slower, more deliberate approach.
Before opening a pull request, expect to spend significant time just understanding the code: how it works, where the change should go, and how it will interact with surrounding logic. Each decision should be made with the broader system in mind.
As you work, continually ask yourself:
- Am I following the system’s existing architecture and design patterns?
- Is this the right module or boundary to introduce this change?
- Does the change maintain cohesion and clarity within its context?
- Am I respecting existing naming, formatting, and code conventions?
These questions help ensure that your contributions fit cleanly into the system rather than create fragmentation or technical debt.
A tactical mindset may work in the short term, but without a strategic approach, chaos accumulates, and the result is a codebase that becomes harder and riskier to change. Ultimately, strategic thinking is what allows systems to remain sustainable over time, rather than collapse under the weight of their own complexity.
Getting Oriented
Onboarding onto an existing software project involves more than just setting up your environment and reading a few docs, it requires a deliberate effort to understand how the system works, how healthy it is, and where it hurts. Mature codebases often contain years of accumulated decisions, trade-offs, and complexity. Before contributing meaningful changes, it's crucial to gain architectural awareness and identify areas that need attention or caution.
This section provides practical techniques for ramping up effectively. You’ll learn how to investigate the system’s structure, use refactoring and diagramming as tools for exploration, assess the overall health of the codebase, and surface pain points that may not be obvious at first glance. By approaching onboarding as an active, investigative process, you'll build the context needed to contribute with confidence and care.
Understanding the System
The first step when working on an existing system is to understand how it works. This can be difficult, especially if the system is poorly documented or if the original developers are no longer around. However, it is crucial to have a good understanding of the system before you start making changes. If you don't understand how the system works, you are likely to introduce bugs or break existing functionality.
Get Things Running Locally
Before diving into the code, it's essential to get the system up and running on your local machine. This process can vary significantly depending on the technology stack, so be sure to follow any setup instructions provided in the documentation. If the setup process is complex or poorly documented, don't hesitate to reach out to your team for assistance. As gaps are identified, take time to contribute to the documentation to help future developers.
Once you have the system running locally, take a little time to explore its basic functionality. Use the application as an end user would, and try to understand the various features and workflows. This hands-on experience will help solidify your understanding of the system and reveal areas that may need further investigation.
Reviewing Documentation and Code
The next step in understanding an existing system is to review any available documentation. This can include architecture overviews, design documents, diagrams, README files, inline comments, or even ADRs (Architecture Decision Records). The goal at this stage isn’t to master every detail, but to build a high-level mental model of the system, its major components, how they interact, and what responsibilities they hold.
Good documentation can save you hours of guesswork. Look for materials that explain how the system is structured, what problems it was designed to solve, and why certain technical decisions were made. Design artifacts like diagrams and ADRs offer valuable context for understanding not just how things work, but why they were built that way.
In well-documented systems, this process is straightforward: read the docs, explore the code, run the tests, and ask questions of colleagues or past contributors. But most real-world systems are not ideally documented. In those cases, you'll need to rely heavily on the code itself to uncover how things truly function.
Remember that the code is always the ultimate source of truth. Documentation may be outdated or incomplete, but the behavior defined in the code is what the system actually does. When in doubt, trust the code over the comments.
It's also important to accept that you won’t understand everything. With any large, mature codebase, there will always be parts that are obscure, complex, or just unfamiliar. That’s normal. Your aim isn’t perfect comprehension, it’s to develop enough understanding to make safe, thoughtful changes without breaking existing behavior.
Scratch Refactoring
One of the most engaging and hands-on ways to get comfortable with a new codebase is through scratch refactoring. This technique involves making small, exploratory changes to the code, not to improve it immediately, but to better understand how it works. Think of it as poking and prodding the system in a low-stakes environment, just to see how it behaves.
Unlike traditional refactoring, scratch refactoring has no specific goal other than learning. Rename a few variables to improve clarity, extract a method to understand its purpose, or reorganize a small section of logic just to see if it still passes the tests. These exercises help you get past the initial hesitation of touching unfamiliar code and give you a clearer sense of its structure, patterns, and quirks.
The key rule is this: throw your changes away when you're done. No one other than you should ever see these changes. This isn't the time to "clean things up" or open a pull request. Instead, jot down any insights or ideas for future improvements and revisit them when you have the full context and a proper reason to make a change.
Scratch refactoring is not just about comfort, it's about discovery. It reveals hidden dependencies, fragile spots, and opportunities for design improvement. Over time, this process helps you build both confidence and intuition in the codebase, without the pressure of committing production-ready changes.
Diagramming as an Investigation Tool
Most real-world systems are far too complex to fully grasp just by reading code. Trying to keep every interaction, data flow, and component in your head creates unnecessary cognitive strain. That’s where diagramming comes in. It gives you a powerful way to externalize your mental model and reason about the system more effectively.
Even simple, hand-drawn diagrams can be incredibly useful. A high-level component map, a flowchart of a key process, or a sequence diagram showing how a request moves through the stack can all provide clarity that raw code often lacks. These visuals allow you to spot patterns, inconsistencies, and potential issues that might be hard to detect otherwise.
Sequence diagrams, in particular, are especially helpful when exploring unfamiliar behavior or debugging complex issues. They allow you to trace the flow of control, understand how data moves between components, and pinpoint where things may be going wrong. Mapping out even one troublesome feature can surface architectural insights that benefit your broader understanding.
Don’t worry about perfection, these diagrams are for you, not for posterity. Treat them as disposable, iterative thinking tools. The act of drawing often leads to new questions and deeper understanding.
Pair Programming
Pair programming is a powerful technique for accelerating your understanding of an unfamiliar codebase. By working closely with a teammate, generally a more experienced developer or someone familiar with the system, you benefit from real-time knowledge sharing, immediate feedback, and collaborative problem-solving.
When navigating complex legacy systems, pairing helps you ask the right questions and uncover hidden assumptions faster than working alone. Your partner can provide valuable context on architectural decisions, historical quirks, or tricky edge cases that may not be documented anywhere. At the same time, explaining your thought process aloud helps solidify your own understanding and exposes gaps or misunderstandings early.
Pair programming also reduces the fear and uncertainty that often come with touching unfamiliar code. Instead of guessing in isolation, you can validate ideas instantly and explore alternative approaches together. This shared exploration encourages better design decisions and increases confidence that your changes won’t inadvertently break existing functionality.
Beyond immediate learning, pairing fosters a culture of collaboration and collective code ownership, which is especially important in maintaining and evolving complex systems over time.
In short, pairing can be more than just a coding practice, it can be a learning strategy that transforms onboarding from a solo challenge into a team effort, making it easier, faster, and more effective to understand and contribute to established codebases.
Measuring System Health
Before you start making changes to an existing system, it is important to understand the current state of the system. This includes things like:
- How many bugs are currently open?
- Are existing tests passing consistently? Are they being run?
- How long does it take to deploy a change?
- How long does it take to fix a bug or to add a feature?
Answering these questions will give you some idea of the health of the system. They won't tell you everything, but they will give you a good starting point. If you find that there are a lot of bugs, or that tests are failing, or that it takes a long time to deploy a change, you know that there are problems that need to be addressed.
A healthy system will have a low defect rate, reliable comprehensive tests, and a fast deployment process.
According to the State of DevOps Report these metrics correspond to high-performing teams. These teams deploy code 208 times more frequently, with 106 times faster lead times, and recover 2,604 times faster from incidents.
Review Recent Changes
An effective way of assessing a system’s health is by examining its recent changes. Reviewing the commit history, pull requests, and issue tracker provides valuable insights into the development activity and the nature of work being done.
A steady flow of small, focused changes often indicates a healthy, actively maintained system. It shows the team is continuously improving the codebase, addressing issues incrementally, and managing technical debt effectively. Conversely, a scarcity of changes, or a pattern dominated by urgent bug fixes, may suggest the system is struggling, perhaps due to stagnation or underlying instability.
Pay close attention to the size and complexity of changes. Frequent large, sweeping updates can signal architectural challenges or rushed development, both of which increase the risk of introducing bugs and complicate code reviews and testing.
Beyond the code itself, reviewing recent changes offers a window into the team’s workflows and processes. Observing their branching strategies, code review practices, or tooling choices can reveal how the team collaborates, manages quality, and enforces standards, even when these processes aren’t formally documented.
By understanding recent development patterns, you not only gauge the system’s current health but also gain context on how to approach making your own changes effectively.
Review Bugs and Defect Handling
Examining the history and nature of reported defects can also give insights into the underlying health of a codebase. Patterns in bugs can often point to deeper structural issues. For instance, if the same module is the source of repeated issues, it may be poorly designed, difficult to test, or overly complex.
Look for trends in the bug tracker or incident logs: Are bugs clustered around a few components? Are they hard to reproduce or fix? Do they reappear after being resolved? These are signs of fragile areas in the system, and potentially of broken processes.
The speed and consistency with which bugs are diagnosed and resolved also says a lot about the codebase. Quick, low-risk fixes often indicate a system that is well-structured and well-understood. In contrast, slow or high-risk fixes suggest a brittle or opaque design, where developers lack confidence in the consequences of change.
Bugs are not just problems to fix, they can also be signals. Paying attention to where and why they happen can help guide where to focus maintenance, testing, and design improvements.
Evaluate Test Quality
The quality and structure of a project's test suite offer strong signals about the overall health of the codebase. A well-maintained, fast, and reliable test suite is often a sign of a codebase that's thoughtfully designed and actively cared for. Conversely, weak or brittle tests can indicate deeper issues, like tangled dependencies, poor separation of concerns, or unclear requirements.
Start by looking at test coverage, but don’t rely on numbers alone. High coverage doesn't guarantee meaningful tests. Instead, ask: Are the tests readable? Do they clearly define expected behavior? Do they isolate units of logic effectively?
Also examine how the team uses tests in their workflow. Are tests run automatically during development and deployment? Are they trusted as a safety net for making changes? A healthy test process builds confidence and enables safe iteration.
A flaky, slow, or overly complex test suite can discourage developers from running or trusting the tests, which often leads to bugs slipping through and fewer safe refactorings over time.
Identifying Pain Points
Codebase health is rarely uniform, some areas tend to be much more difficult to work with than others. These problematic regions, or pain points, may not always be outright broken, but their complexity, brittleness, or lack of test coverage makes them high-risk and frustrating for developers. Identifying these zones is key to targeting improvements where they’ll have the most impact.
The goal here isn’t to find isolated bugs, but to locate parts of the system that resist change. These are the areas where features are hard to implement, bugs are easy to introduce, and developers are most likely to make costly mistakes.
There are two complementary ways to uncover pain points:
- Subjective Assessment: Talk to the developers who work on the system. Ask which parts of the code they dread modifying, or where they’ve encountered the most friction. This kind of experiential knowledge is often more insightful than any metric.
- Objective Analysis: Use metrics like cyclomatic complexity, test coverage, and code churn. Of these, code churn, or how often a file or module changes, is particularly useful. High churn areas that also have low test coverage or high complexity often correlate strongly with pain points.
Together, these methods provide a balanced view. The subjective perspective highlights developer experience, while metrics offer a repeatable way to identify trouble spots. Focusing your improvement efforts on these areas can significantly improve maintainability, reduce bugs, and increase team confidence.
When you spot something that looks like a pain point, don't just jump in and start refactoring. Just because a region of the code is complex or lacks tests doesn't mean it is causing problems. A complex region of the code that is stable is not a pain point. This is a case where the opinions of the developers who work on the system are the most important.
A General Framework for Change
Working with an established codebase can feel daunting, but a strategic and methodical approach can help you make safe, sustainable changes. Having a framework in mind can guide you through the process, ensuring that you respect the existing architecture while still making meaningful improvements.
1. Understand the Issue and Subsystem
Before you touch any code, take the time to understand the issue you want to address. Is it well defined, or is it a vague request for "better performance" or "more features"? Clarify the problem and its context. What are the requirements? What are the constraints? Ask questions to ensure you have a clear understanding of what needs to be changed and why.
Review the target region of code in more details. Look at the architecture, dependencies, and how different components interact. Read any related documentation, trace through the relevant code paths, and observe how the system behaves under real conditions.
Your goal is to identify to find the right place to make your change, and to understand how it will fit into the existing structure. This is not just about understanding the current behavior, but also about understanding the intent behind the design choices that have been made. This will help you avoid making changes that conflict with the system's architecture or that introduce new complexity.
2. Evaluate the Risk
Once you understand the change you want to make, consider its potential impact. Is the code you're modifying part of a critical workflow, like authentication or payments? Could it affect shared components or other teams’ services? If tests fail, will you know why, and will you be able to fix them quickly? The more critical or fragile the area, the more cautiously you should proceed.
If the change is risky, consider whether it can be broken down into smaller, less risky pieces. This can help you avoid introducing new bugs and make it easier to validate your changes. If the change is too large or too risky, consider involving more senior developers or architects to help you assess the impact and plan the change.
3. Assess the Scope of the Change
Define the boundaries of your change. Determine whether it’s purely local, or whether it touches multiple modules or services. Can the work be broken down into smaller, incremental pieces? Keeping changes narrowly scoped makes them easier to review, test, and reverse if needed.
If possible, look for ways to isolate your change from the rest of the system. This could mean creating a new module, using a feature flag, or introducing a new interface. The goal is to make it easy to test and validate your change without affecting the rest of the system.
4. Test and Validate
Testing is essential. If good tests already exist, use them to verify the current behavior and ensure it remains stable. If tests are lacking, consider writing "characterization tests" that document how the system behaves before you change it. Once your change is made, validate it thoroughly using local runs, automated tests, and staging environments if available. Don’t just look for correctness, watch for unexpected side effects.
We want a set of tests that can act as a clamp, preventing us from breaking existing functionality. These tests should cover the current behavior of the system, and should be run before and after your change. If the tests fail, you know that something has gone wrong, and you can investigate further.
5. Make Your Changes
Once you’ve developed a clear understanding of the system, assessed the risks, and outlined your testing strategy, you can begin implementing your changes. Keep them small, focused, and incremental, avoiding multiple unrelated modifications in a single pass. This makes your code easier to review, test, and reason about.
As you write code, adhere to the system’s existing conventions and architectural patterns. Aligning with the current style helps your changes blend naturally into the codebase and makes them easier for others to maintain. If you need to introduce new concepts or patterns, do so thoughtfully, ensuring they’re consistent with the system’s direction and understandable to your teammates.
6. Consider Long-Term Impact
Lastly, take a moment to think about what your change means for the system’s future. Does it align with the architecture and coding conventions? Does it improve clarity and maintainability, or does it introduce new complexity? Strategic thinking matters more in legacy systems, where a careless change today could create lasting problems tomorrow. Write code that future developers, including you, will be able to understand and trust.
Fixing Bugs
Fixing bugs in an existing system is a distinct process from adding features or performing refactors. The right approach depends on the nature of the issue, both in terms of what’s broken and how severe the impact is. A minor glitch that affects only developers in a test environment warrants a different response than a critical production issue affecting users.
While it’s tempting to jump straight to writing a fix, effective bug resolution demands a more deliberate process:
- Reproduce the bug
- Identify the root cause
- Write a failing test
- Fix the bug
- Verify the fix
Many newer developers are eager to skip straight to step 4, but that shortcut often leads to shallow fixes that don’t address the real problem, or worse, introduce new ones. Without a clear understanding of the root cause, you may end up patching symptoms instead of solving the actual issue.
Take the time to fix bugs properly. A well-understood fix is far more valuable and sustainable than a quick one.
1. Reproducing the Bug
The first step when fixing a bug is to reproduce the bug. This might seem obvious, but it is important to make sure you understand exactly what is happening before you start making changes. If you can't reproduce the bug, you won't be able to verify that your fix worked. Hopefully, the bug report will include steps to reproduce the bug. If not, you will need to try to work through that process for yourself. In many cases, this is the most time-consuming part of the process, especially if the bug is intermittent or if the system is complex.
Strategies for Hard-to-Reproduce Bugs
- Match the environment: Ensure your setup mirrors the one where the bug occurred, including OS, config, data, dependencies.
- Check logs and metrics: Review any output from the time of failure for clues.
- Add instrumentation: Use temporary logging or assertions to narrow down the problem area.
- Stress and simulate: Use load testing, thread sanitizers, or artificial delays to surface timing-related bugs.
- Automate reproduction: Create small scripts or loops to repeatedly test suspected paths until the bug appears.
- Ask for more info: If possible, talk to whoever reported the issue to gather more context.
Once you can consistently trigger the bug, you're in a solid position to trace its root cause and verify your fix.
2. Identifying the Root Cause
After reproducing the bug, the next step is to understand why it’s happening. This can be challenging, especially in complex systems or when dealing with intermittent behavior. To get there, you’ll need to trace the flow of control through the system. Use debugging tools, strategic logging, or even manual inspection to pinpoint where things start to go wrong.
Visual tools like sequence diagrams can be especially helpful here. Mapping out the system’s behavior step by step helps reveal unexpected interactions, overlooked edge cases, or timing issues that may be difficult to see in the code alone.
If the bug is recent, reviewing recent changes in the codebase can offer valuable clues. Look at relevant commits, pull requests, or configuration changes that may have contributed to the issue. Keep in mind, though, that the bug might not be newly introduced, it may have been latent in the system for some time and only surfaced under new conditions. Avoid jumping to conclusions or assigning blame based solely on who last touched the code.
The goal is to move beyond the symptom and isolate the deeper issue so your fix addresses the real problem, not just the visible failure.
3. Writing a Failing Test
Before making any code changes, the next critical step is to write a test that reproduces the bug, a test that fails for the right reason. This might seem counterintuitive at first. Why write a test you know will fail? But it's a key part of a disciplined bug-fixing process.
A failing test gives you a concrete way to demonstrate the problem. It validates that you understand the conditions under which the bug occurs and confirms that your understanding of the system's behavior is correct. It also sets a clear goal: once the test passes, you know the bug is resolved.
Additionally, this test acts as a safety net for the future. If the same issue reappears later, due to a regression or a related change, you’ll be alerted immediately. In this way, you're not just fixing the bug; you're increasing the resilience of the system and improving its test coverage.
In some cases, reproducing the bug in a test might be difficult, especially if it involves timing issues, concurrency, or complex environmental dependencies. When that happens, consider using mocks, stubs, or special test harnesses to isolate the behavior and create a reliable failure condition.
Ultimately, the failing test is your proof: that the bug existed, that you understood it, and that it is now fixed, on purpose, not by accident.
4. Fixing the Bug
Now that you've identified the root cause and written a failing test, you're ready to begin making changes to fix the bug. Your goal is to correct the behavior without introducing new problems, something that can be challenging in a complex or poorly understood codebase.
Approach the fix with care. Make small, focused changes rather than broad rewrites. This helps you isolate the impact of each modification and makes it easier to pinpoint where things go wrong if you run into issues.
After each change, run your tests, not just the failing one, but ideally the full test suite. This helps ensure you're not unintentionally breaking unrelated parts of the system.
Pay attention to the intent of the original code, not just what it does. Sometimes a fix that seems obvious may violate assumptions made elsewhere in the system. Review related components and double-check for hidden dependencies or coupling.
As you make progress, keep in mind that this is also a chance to improve your understanding of the system. If something is unclear, document it or add a comment. Future you or other developers will thank you.
Finally, resist the urge to "clean up" nearby code while fixing a bug. Keep your changes as minimal and reversible as possible. If you see cleanup opportunities, note them down and address them separately in a future refactor.
By keeping your changes small, test-driven, and purpose-specific, you’ll maximize the likelihood of a clean, reliable fix.
5. Verifying the Fix
Once you've made your changes, the next step is to validate that the bug has actually been fixed, and that you haven't broken anything else in the process. Start by running the full test suite, including the new test you wrote to capture the bug. If all the tests pass, that’s a good sign, but it’s not the end of the process.
Manually verify the fix by trying to reproduce the bug using the original steps. Even if the test passes, it's important to confirm that the bug no longer appears under real conditions. Sometimes, automated tests may not perfectly replicate production behavior, especially in complex systems with external dependencies or asynchronous processes.
If the bug is gone and the tests are green, you're in good shape to prepare your code for deployment. But if the test fails or the bug still occurs, don’t patch it up blindly. Re-examine your assumptions, revisit your root cause analysis, and trace through the code again. You may have only addressed part of the issue, or misunderstood the nature of the bug.
Finally, review your changes with a colleague or team member. A fresh set of eyes can catch potential issues you might have missed and provide additional context or insights. This collaborative review helps ensure that your fix is robust and that the code remains maintainable.
Safely Adding Features in Legacy Systems
Adding features to legacy systems that are no longer under active development requires a different mindset than working on modern, well-maintained codebases. These systems are often poorly documented, lack tests, and may be unfamiliar even to the current team. Despite their flaws, they’re still delivering value, which means stability is critical.
Your goal isn’t just to add functionality, it’s to do so without disrupting what already works. That requires careful planning, extra caution, and a willingness to work around the system’s limitations rather than fight them head-on.
Working With Seams
If the existing system is not in a healthy state, it's tempting to just add your new code where ever it fits and move on. After all, if you are already looking at a mess, what's a few more lines? However, this is a mistake. Adding new code to a messy system just makes it messier and harder to validate. Instead, you should look for places in the code where you can safely insert your new behavior. You want to be able to test the new code in isolation, so we need to find a seam.
The term seam comes from Michael Feathers' book Working Effectively with Legacy Code. He states, "a seam is a place where you can alter behavior in your program without editing in that place." In other words, you can insert code at a seam to change the behavior of the program without changing the code around the insertion point. Seams allow you to isolate the new code from the existing code, and to test the new code in isolation. This makes it easier to add new features without breaking existing functionality.
Most seams are found at the boundaries of the system components. This could be a function call, a class instantiation, or an internal dependency.
Sprout Method vs Wrap Method
The excellent examples for this section are taken from this article by Nicholas Carlo, which offers a concise summary of Michael Feathers' book Working Effectively with Legacy Code.
When adding a new feature to an existing system, you have two main options: the sprout method and the wrap method. With the sprout method, you add a call to a new function that contains the new behavior. With the wrap method, you add a wrapper around the current function that contains the new behavior.
Using the same example for both wrap and sprout, here's our starting code. Let's say that you have a function called postEntries
that is not supported with good tests, and you want to eliminate duplicate entries in the input. Ideally, we'd add thorough test for postEntries
before we start, but it's unlikely that we have time for that. Here's our starting code:
class TransactionGate(private val transactionBundle: TransactionBundle) {
// ... a lot of code here
fun postEntries(entries: List<Entry>) {
entries.forEach { entry ->
entry.postDate()
}
// ... a lot more code here
transactionBundle.listManager.addAll(entries)
}
// ... even more code here
}
- Create a new function that contains the new behavior some place outside of where you need the functionality.
- Test the new function thoroughly in isolation.
- Add a call to the new function where you need the functionality.
Create a new function uniqueEntries
that eliminates duplicates. Once finished we can thoroughly test it in isolation.
Then, add a call to uniqueEntries
in postEntries
:
class TransactionGate(private val transactionBundle: TransactionBundle) {
// ... a lot of code here
fun postEntries(entries: List<Entry>) {
val uniqueEntries = uniqueEntries(entries)
uniqueEntries.forEach { entry ->
entry.postDate()
}
// ... a lot more code here
transactionBundle.listManager.addAll(uniqueEntries)
}
// ... even more code here
}
- Rename the existing function to a new name.
- Create a new function with the old name and signature that calls the renamed function.
- Add the new behavior to the new function. This can be before or after the call to the renamed function.
class TransactionGate(private val transactionBundle: TransactionBundle) {
// ... a lot of code here
fun postEntries(entries: List<Entry>) {
// Do some clever logic to dedupe entries
postUniqueEntries(entries)
}
private fun postUniqueEntries(entries: List<Entry>) {
entries.forEach { entry ->
entry.postDate()
}
// ... a lot more code here
transactionBundle.listManager.addAll(entries)
}
// ... even more code here
}
To test this work we would need to mock the call to postUniqueEntries
and verify that it is called with the correct arguments.
Refactoring
Sometimes you're lucky enough to have time to make significant improvements to an existing system design. Whether you're addressing long-standing technical debt or cleaning things up as part of a larger change, refactoring gives you the chance to reshape the system for the better.
Refactoring is the process of improving the internal structure of code without altering its external behavior. It’s not about adding features or fixing bugs, but about making the code easier to understand, modify, and extend safely.
As systems evolve, they naturally accumulate complexity. Temporary fixes, changing requirements, and shifting priorities can leave behind code that’s brittle, redundant, or difficult to follow. Refactoring allows us to claw back some simplicity and bring clarity and structure back into the system, one small step at a time.
Used thoughtfully, refactoring reduces risk, improves design alignment, and builds a stronger foundation for future development. It turns messy code into something that developers can work with confidently.
When to Refactor
Most refactoring happens alongside regular feature development. The best times to refactor are just before or immediately after adding new functionality. If the code you need to work on feels messy, start by cleaning it up first. Commit those cleanup changes separately, then proceed with adding your feature. This approach helps ensure that new work is built on a stable, clear foundation.
If, after adding your code, you find the area harder to understand or maintain, take a moment to tidy it up while the context is still fresh in your mind. Refactoring at this stage is effective because you’re already familiar with the changes and can improve the design without introducing unnecessary risk.
Refactoring in the middle of developing a feature is usually not advisable, as it can cause confusion and make debugging more difficult.
Sometimes, focused refactoring is needed independent of new features or bug fixes. These targeted efforts should be intentional and aimed at addressing specific technical debt or improving the design of particular modules or components. However, such standalone refactoring should be approached with care and clear purpose, as it’s not the typical workflow.
Key Techniques
Here are some common refactoring techniques used to improve system structure and maintainability. This is not an exhaustive list, refactoring should always be tailored to the specific problem you’re trying to solve and the context of your codebase. Choosing the right technique depends on the goals you want to achieve and the challenges you face in the code.
- Extract Method: Break down large or complex functions into smaller, more focused methods. This improves readability and makes code easier to test and reuse.
- Rename Variables and Methods: Give variables, functions, and classes clear, descriptive names that reflect their purpose. This reduces confusion and improves code clarity.
- Inline Method or Variable: Replace a method or variable that does very little or is used only once with its actual content. This simplifies the code and reduces unnecessary indirection.
- Move Method or Field: Relocate methods or data fields to the classes where they logically belong. This helps maintain proper encapsulation and organizes responsibilities better.
- Replace Conditional with Polymorphism: Use inheritance or interfaces to replace complex conditional logic, making code easier to extend and maintain.
- Introduce Parameter Object: Group multiple parameters into a single object to simplify method signatures and improve readability.
- Simplify Loops and Conditionals: Refactor complex loops and conditionals into clearer, more straightforward constructs, sometimes by extracting parts into separate methods.
These techniques help keep code clean, modular, and easier to maintain, especially as systems grow and evolve.
Refactoring Tools
Tools can make refactoring faster, safer, and more efficient by automating repetitive tasks and providing insights into your code. Modern IDEs like IntelliJ IDEA, Visual Studio, and Eclipse offer built-in refactoring support for common techniques such as renaming, extracting methods, and moving classes. These tools automatically update all references, reducing the risk of errors.
Static analysis tools help identify code smells, duplicated code, and complexity hotspots that might benefit from refactoring. Code coverage and mutation testing tools also guide improvements by showing where tests are weak or missing.
Additionally, version control systems make it easy to track changes, experiment with refactoring in isolated branches, and safely roll back if needed. Combined, these tools support a smoother refactoring process and help maintain code quality over time.
Conclusion
Design doesn’t stop once a system is built, it evolves with the codebase. In established systems, good design choices are less about grand architecture and more about strategic, incremental decisions that preserve stability while enabling change. Working within the constraints of legacy code requires patience, empathy for the existing structure, and a deep respect for the complexity that time inevitably brings.
Careful design thinking helps reduce risk, guides safe improvements, and ensures your contributions add long-term value rather than short-term fixes. Whether you’re refactoring a brittle module, introducing a new feature, or fixing a subtle bug, the principles of thoughtful design, modularity, clarity, cohesion, and restraint, remain essential.
By approaching established systems with a strategic mindset, you help extend their lifespan, ease future development, and improve the experience for everyone who comes after you. In legacy code, design is less about perfection and more about responsibility.