03 March 2022
10 architecture tips for working with legacy software systems
Some say developers are divided into two groups. There are those who worked on legacy applications and those who will be working on a legacy system in the future. In my experience, this is absolutely true. Even though greenfield projects are fascinating, there are skills and strategies that we can gain only from working with outdated, or not-so-modern pieces of software. In this article, I will give some architecture tips which I hope will help you tackle legacy software challenges.
First, let’s spend some time to understand why do we even bother maintaining outdated systems or codebases that remember dinosaurs.
Quick disclaimer: as always, there is no magic pill, so don’t look for one. You’ve been warned!
The code or not the code?
We, programmers, focus on writing lines of code, using design patterns, and pursuing new technologies. While these are important pieces of the puzzle, most of us work for “some company” building “some product”.
It doesn’t matter whether we work in the banking sector, global transportation corporation, or NASDAQ top tech companies. Our end goal is to create a cohesive, valuable product that serves its business goals to the end consumers. It sounds obvious, yet I can’t stress enough how important it is to know the purpose of our actions.
Legacy software can be maintained, modernized, or replaced.
Some strategies will work better for different scenarios. Remember to choose the right tool wisely.
1. Asynchronous processing in a separate service
Microservices and similar patterns are not new concepts anymore. But what if the project you’ve been assigned has been operational for years or even decades?
Chances are you will find many candidates to be processed asynchronously, but the whole application is a big monolithic juggernaut. By separating these functionalities, you can reduce the size of the main application and make scalability less expensive. Here are some features that you might want to refactor:
- Report generation for transactions made at the end of the day/month/year.
- Data import from 3rd party providers
- Data synchronization between two or more sources
- Image resizing
In this example, we will look at updating a lookup table for routing client requests between different parts of a system.
Let’s imagine that we have a multi-tenant application that has a common entry point exposed via REST API. We receive an HTTP Request which we somehow need to route into the desired tenant instance. This lookup table could be updated once a month or every time a new tenant is onboarded. Because of the scale, we could have millions of associations as each tenant has its own users.
2. Get covered
Legacy software sooner or later will require “some small adjustment”, “a very small feature” or “business flow amendment”. Most programmers feel more comfortable with working on their own code – no surprises there.
But again, sooner or later the situation will change inevitably, and we will need to take ownership of a module from some other team member. This can be especially stressful for new team members and less experienced programmers.
The biggest favor you can do for yourself and your colleagues is to make sure your code has good coverage and that the tests are not false positives. Another benefit of having good test coverage is the self-documenting codebase which shines especially well for outdated software.
Legacy projects tend to have Confluence pages with the latest edits made months ago more often than we want to admin. Usually, implementation is a bit different than description as well. Making changes in a module with good coverage gives you the confidence needed to deliver new features. It makes onboarding team members easier and gives you a good overview of the business paths covered.
3. Adapters to rule them all
In my experience, more often than not I have worked with at least one 3rd party service provider in a project. Sometimes it was a nice REST API, sometimes it was very well designed GraphQL. On the other hand, I received XMLs with changing schema or text files uploaded to on-prem SFTP server in custom domain-specific format.
Adapters are great not only for making sure we all speak the same language within the already operating system (for example we need to translate XML into JSON as our system operates on JSON) but also provide a great safety net via abstraction.
In my experience, the SaaS market is very competitive and the 3rd party providers we use today might not be as great when the current contract ends. When decommissioning an old 3rd party provider, we can just throw the adapter away and create a new adapter dedicated to a new provider. At the same time, our internal API used by multiple consumers will be consistent and won’t require any change.
An additional benefit of such a design is the ease of testing. If we have a good test strategy, changing underlying components should not result in a change of behavior. Any hot spots will be marked by failing tests focusing our attention on those areas. I already said that tests are a must, right?
4. Leverage SaaS services
Throughout the recent decade, the SaaS market exploded. A lot of legacy systems are full of features for PDF invoice generation, transactional email send-outs, service notification, localization, or real-time chat.
When legacy software starts to fail in these areas, take a quick look at current service offerings in the market for ready-made SaaS products. You can save yourself a lot of work (and money) by leveraging 3rd party providers for important tasks. It’s an especially good alternative when your goal is to sustain an outdated system only as long as the v2 version is being developed.
5. Do your research first
Sometimes, you can see on your roadmap that in 6 months’ time the current setup will be deprecated. Before you commit multiple hours of your work into developing new extensions, integration, or solving rarely experienced bugs, take your time to estimate the work needed. In many cases, legacy software can deliver business value as it is. Maybe it will require more manual work for the development team, it’s not a bad thing. Estimated effort isn’t always worth it when you take the big picture into account. Make sure to make a good assessment of the situation first.
6. Make use of transitional architecture and disposable components
Every time I work on legacy software modernization, I experience the need to make changes on a step-by-step basis. The more features we have, the harder it will be to migrate processes from legacy systems to brand new ones. In most cases, it’s not even possible to make it in one go.
You may ask why is it the case?
As systems grow and get older, functionalities, tech stack, or architecture decisions might become obsolete. Meanwhile, we have to deal with huge numbers of consumers because of the production environment.
We can’t just throw a service away and make a new one from scratch. One of the most useful patterns to tackle such a scenario is Strangler. Effectively, what we do is we place a facade in front of the outdated software. Then we develop V2 iteration incrementally, and over time, Strangler delegates more and more responsibilities to the V2 service. In the end, once legacy is no longer in use, we can decommission it along with Strangler (it’s a great representation of transitional architecture).
7.Version your API
One of the easiest things you can do — for which you will thank yourself later— is to version your REST API. When we are in front of the drawing board designing APIs, they are always pieces of art.
Unfortunately for us, most systems degenerate over time. That’s especially true if they have been operational for many years. Versioning allows us to develop our APIs under better standards from our experience of proven (or not) endeavors from the past.
This strategy allows us also to change our core patterns (move from webhook to streaming API, change pagination type or make better separation of entities) with ease. All of these can be achieved and exposed to the end-users while still maintaining V1. End consumers will have time to make the switch and test V2, report feedback, and help you improve before you deprecate V1 for good. There is no reason not to do it.
8. Find your entry point
If we look at monolithic legacy software under the microscope, we may discover many interesting findings. Let’s assume that our legacy is divided into multiple submodules/contexts.
These probably will be leaking here and there, but we are not going to focus on this. What is important is that there are “seams” which hold some parts together. Basically, what we are looking for are places where we can easily make surgical cuts to these separate subparts along the seam lines.
Seams will typically be placed at the borders of either business context (Bounded Context from DDD is a great example) or at the edges of architecture boundaries (for example edges of already established abstraction). Surprisingly, you may end up with a couple of smaller pieces from different areas of the system which will be logically connected but by many better or worse reasons were placed all over the place along with system life.
The art of refactoring and splitting monolithic apps is for another story., for the purpose of this article, please remember to take your time and plan your actions. As you are working on a live organism, it’s better to make careful moves — especially around core-business parts.
If you are interested in reading more about the journey of software discovery, you might want to read more about:
- event storming
- domain mapping
- Wardley Mapping
- customer journey mapping
9. Dark launch
This strategy is especially useful when developing new features and extensions to an already working system. Our goal is to deploy our functionality in a way where it’s triggered but its results are ignored (end users are not aware of them). Thanks to this strategy combined with small incrementally deployed functionalities, we can assess if a newly developed feature meets performance requirements, validate the business requirements under live load without disrupting service or users as everything is working “behind the curtain”.
10. Don’t be afraid to be in the middle
I find the Event Interception pattern especially useful for multi-account AWS environments. In the following example, I’ll illustrate the following scenario:
- The frontend application is communicating with the backend server which then publishes messages on SQS. All of the infrastructure described except the SQS above belongs to one team and one AWS account. The service is able to publish messages on SQS through exclusively defined IAM policy
- The second team owns SQS as well as two worker groups. These workers are pulling messages from SQS and performing processing
Goal: To route messages into multiple targets while being able to configure routing easily
Solution: Add a second SQS to the first account. Then, create a Router service that will be pulling messages from that SQS. Based on routing strategy, pass it further to an already known SQS in the second account or decorate a message and pass it to another account
Here is the architecture overview before the operation:
And here is after introducing event interceptor in the middle:
By having the interceptor in the middle, we are giving ourselves the ability to send some of the events into a different direction so we can plug in new services. We can also transform those events by decorating them with an additional payload.
Is legacy software as scare as it’s painted?
In this article, we went through 10 tips and ideas to make your life easier with working on, maintaining, and modernizing legacy systems. After all, this software is not as scary as it might seem. By having good tools and strategies you should be able to tackle most of the challenges you encounter. After all, when you check who wrote it, then well. You might be surprised.
Yeah, never mind. Actually, it’s not that bad…
Is your legacy system making you fall behind and panic about the future?
Request refactoring advice from certified software architects and their developers.