It’s a fact of working in software that at some point you're going to work with legacy systems.
There are many different ways to define ‘legacy system’. In Working Effectively with Legacy Code, Michael Feathers defines it as any untested system. We might also consider any system where the original developer has moved on to be a legacy system. We could even argue that anything written more than six months ago is legacy code.
Regardless, we all know what legacy systems look like. They often have very messy code. Their abstractions can be quite leaky, with different layers being accessed either indirectly or directly. Their implementation can become tangled up in their API – code you'd rather not touch if you don't have to.
But inevitably, you do have to. At some point, a need will come in. Maybe you need to extend the system to handle a new case. Or there is a scalability or performance need that requires upgrading the underlying technology. Or the system is just causing too many bugs, and you need to rework it without breaking everything that depends on it.
In my team’s case, we had an internal role service that only had one way of defining a role –a static list of users. It was extremely efficient, and worked well for this use case. But it also had implementation details about the role metadata tangled up deeply with the API, was optimized for a storage technology we were no longer using, and was generally a pain to work with.
As long as we just left it, it worked fine. But suddenly we had a new requirement to be able to create dynamic roles defined by a query against the database, and we had to figure out how to modify this legacy system at the core of our product without bringing down everything along the way. While there are a few ways we could have approached this, there is one that I have found to be the most reliable: creating a ‘seam’ in the application behind which you can safely implement the change.
Here I’m introducing the concept of application seams, and sharing how you can use them to successfully refactor your legacy systems.
What is a seam?
In Working Effectively with Legacy Code, Feathers describes a seam as ‘a place where you can alter behavior in your program without editing in that place.’ I interpret this as a clean interface behind which you can create a change without disrupting existing behavior.
This type of interface allows you to decouple different parts of your application and isolate implementation changes to a single system, rather than having to change behavior throughout the entire system all at once.
Let's dive into what this looks like.
Creating the seam
Before you start to implement your new functionality or backend, you need to create the seam.
First, identify a location where you can create a clean API. This often involves ‘hoisting’ or ‘wrapping’ low-level functionality that is being accessed directly up into a higher level service or class.
Next, refactor the service to create this API, introducing no functional changes. This often includes writing a bunch of unit tests for the new API. These unit tests serve the dual purpose of verifying your current implementation and making it safe to introduce changes in the layers underneath the API.
Finally, update all calling code to use the new API. This should also introduce no functional changes, and to the extent possible, be validated by type checking and unit tests.
In my example of the role service, we created a "Role" object with APIs that were divorced from the implementation details of the original role metadata. The externally facing RoleService class was then reimplemented entirely utilizing the API from these role objects.
Using the seam
Now that you’ve created a clean separation between the functionality and the callers within your application, you’re finally ready to introduce the new approach or functionality.
Doing so much work before you get to the actual functionality you care about may feel extremely indirect, but by spending the time to create a seam you have decoupled the implementation details of your API from everything that depends on it. You can now focus entirely on your new implementation and know that if the unit tests are still passing, all of your dependent code should work as well.
In our case, we then started introducing the concept of a ‘role backing’ that different roles could use differently. In addition to our original set of static lists of users, we started introducing sql-backed roles. To all callers of the role service, these behave identically, with the implementation details carefully sequestered behind the role API.
We later ran into situations where an entirely new type of role made sense, and introducing it into our system was now trivial thanks to this very clear API-based seam.
API-level seams are one of the most common types of seam you'll come across, but you might also have object-level seams, service-level seams, or in compiled languages, even file-level seams. Regardless, the process remains the same. Refactor without changing functionality to create a clean place to cut. Add tests at the seam. Update calling code. And only then introduce the new functionality behind the seam.