A Tale of Two Systems: A Primer on Software Architecture (Part 4)
In the last post, we discussed how to apply an architectural design mindset to the process of designing greenfield systems. In this post, we cover applying the same mindset to an existing legacy system.
Tackling Architecture in a Legacy System
Unfortunately, most of the time, developers do not get to live in a greenfield world unburdened by previous developers’ decisions. Most careers are spent in codebases that have existed for a long time. They may have had developers with differing levels of architectural knowledge and commitment. They may have had years of neglect or accumulated shortcuts. The good news is that many of the principles we discussed about greenfield systems are still relevant; they just need to be approached from a different angle.
Everyone knows about the temptation to take shortcuts when writing software. It’s extremely easy to say, “we’ll do it this way ‘for now’” or “this is not exactly the ‘right’ way to do this, but…”. When we speak this way, we implicitly acknowledge that we incur a cost. This is not to say that shortcuts should be absolutely forbidden, but we should be conscious about the tradeoffs that we are making. These costs are often called technical debt. This metaphor is apt because unpaid costs tend to accrue interest. The more shortcuts you take, the higher the cost of maneuvering around those accumulated shortcuts. You can only pay down the interest by refactoring the shortcuts into coherent systems.
For example, imagine a “User” object in a system. At first, it has a few simple fields like name, email, and permissions level. At some point, a developer is working on a UI feature that needs to know the time zone. They have the user configure a time zone, and it gets stored on the user object. Then other developers need more UI settings, payment information, and notification settings. Before long, you have a multi-faceted object that’s imported in a lot of places. Each developer made a small, reasonable decision to add a field, instead of taking the time to develop new objects and interfaces. However, the sum of those decisions means you risk developing a god object or a big ball of mud. Each addition makes it more difficult to change the User object’s responsibilities. This is the “interest” of technical debt.
When working on new features for a legacy codebase, you can imagine the existing code as a third-party dependency. Where possible, treat the implementation details of the existing code as unreliable and abstract your relation to them. This is a way to limit the spread of technical debt. If you assume that the implementation details are reliable, then you tightly couple your new code with the existing structure. This makes it more difficult to refactor the legacy architecture later, and your new code just sinks into the "mud" of the Big Ball of Mud anti-pattern that we discussed in Part 2.
If the legacy code has been neglected long enough, you will often have to deal with architectural drift. This means that the code no longer reflects the original intended architecture or what the documentation says the architecture is. The system may be a mix of different maintainers' styles in archeological layers. You may have to construct a new understanding of the de facto architecture from the ground up. A common strategy would be to follow module or class import directives and see which files reference each other. This can often be assisted by automated tools that produce reference diagrams. (AI agent summaries are also excellent for getting a bird's eye view of the code.) You should keep an eye out for god-objects and layer boundaries. Another strategy is to trace a common use case all the way through the system. Common, complex use cases are most likely to have been given permission to bend the architectural rules of the system. This process should give you an honest view of what the code looks like before you try to fix any of the issues.
Once you have a high-level view of the system, you might be tempted to try to use that to reconstruct a new system from scratch, fixing all the mistakes in the current system. For any complex problem domain, this is extremely difficult. The legacy code is an accumulation of knowledge about edge cases, business rules, and failure states, even if they are undocumented and no mere mortal understands them. A total rewrite will inevitably miss many of these. The more sustainable approach is a slow, piecemeal improvement. This is sometimes called the “strangler fig” pattern, after a family of tropical plants that slowly grow around a host tree and replace it.[1] To use this pattern, you must identify a discrete behavioral component that can be reimplemented cleanly, build it, then verify that it works in production. You’re not allowed to start working on a different piece until you’ve confirmed that things are working. This lets you test your assumptions about the behavior against components that previously relied on the legacy, messy component. (Test cases are also enormously helpful for this.) Progress is made visibly and (hopefully) continuously.
The way to spot which pieces of behavior to target for replacement is by looking for seams. Seams are places where behavior can be altered without changing any code. This includes disk reads, configuration, environment variables, network boundaries, or (if your codebase is a bit more modern) concrete object instantiation.[2] These are all mechanisms that have a natural form of information hiding. They allow opportunities for adapters, test doubles, or alternative implementations. Once you identify these seams and introduce interfaces, you must exercise some discipline. If you allow an exception to reach around the interface, you break the information hiding constraints and create new coupling problems for future developers.
As a corollary, remember that not everything needs to be rewritten. There are some pieces of code so arcane and undocumented that understanding it is more difficult than building a quarantine zone around it. If you can build an effective interface that prevents a legacy module from contaminating your new code, it may not be worthwhile to completely rebuild that module’s internal structure. The question is not “is this well designed?” but “is this hindering future work?”. If the answer is no, then you may be spending more time than you save. Remember, requirement changes are the primary reason that badly designed software components cause development pain. Spend your budget paying down the technical debt that hurts new development the most.
Finally, remember to prevent further decay while you work. It is very easy to give up and keep making short-term choices to keep things running, especially if there are no institutional changes to fix the reasons why the problems appeared in the first place. However, with discipline, you can make small decisions to prevent more debt from accumulating, even without a drastic rewrite. You cannot fix a legacy system overnight, but you can establish the direction of change.
Conclusion
The concepts we’ve covered are neither a checklist to complete at one phase of the project’s lifecycle nor a box of tools to be used occasionally. They are habits of an architectural mindset, which means deliberate thinking about the structure of the system at every stage of its life. Architecture is a continuous practice that you can do every time you add a component, design an interface, or introduce a dependency. The question isn’t whether you will make architectural decisions, but rather whether you make them consciously or unconsciously.
We’ve discussed a lot of traps and pitfalls that can occur while designing a system, and there are plenty more that we haven’t mentioned. It’s easy to feel like good architecture is a lofty, unattainable standard, but the secret is that perfect design is impossible. Even the best architect makes assumptions, accepts tradeoffs, and yes, makes mistakes. The difference is intentionality. Conscious tradeoffs can be managed, but accidental ones lurk in the background and multiply. The best time to make good architectural decisions was at the beginning, but the second-best time is now. Even small architectural improvements will steer your system towards improvement for future developers. The tale of two systems is a tale of different mindsets. The difference between the chaotic system and the comprehensible one isn’t about any single design decision; it’s about the habit of making each architectural decision deliberately.
Martin Fowler coined this term in a 2004 article on his website. Fowler's website is generally an excellent resource for software architecture patterns and vocabulary. ↩︎
Finding seams in legacy code was proposed by Michael Feathers 2004 book Working Effectively with Legacy Code. It’s excellent if you’re looking for a detailed and focused guide to working on legacy systems. ↩︎