Feb 25, 2025 · 6 min read · Real Engineering Lessons
Modernizing a Legacy System Without a Full Rewrite
I worked on a legacy system where a new codebase looked much easier than continuing with the existing one. We modernized it in smaller parts while the old and new paths ran together.
At Softcraft, I worked on the modernization of a legacy system that had become difficult to change. Some business rules were hard to find, changes in one area could affect another one and deployments carried parts of the application that had nothing to do with each other. Starting a new codebase looked much easier and, honestly, I understood why the idea was attractive.
The problem was that the existing application had years of behavior we still needed. Some strange code was only strange code, but some of it was there because of an edge case that somebody had already found in production. When I opened a part of the system for the first time, these two situations could look exactly the same. A rewrite would give us a cleaner repository, but we would still need to find and reproduce the business behavior that mattered while people continued using the platform.
The Existing System
Before changing a module, we tried to understand who used it, which information it owned and what other parts of the application expected from it. Tests and logs helped, but many details came from talking to people who knew the domain or had maintained that area before. Sometimes a behavior that looked unnecessary was actually part of a process used every day, and sometimes we found old code that really could be removed.
This investigation was slower than creating a new structure and beginning the implementation, and a rewrite would have required the same work. Smaller changes allowed us to compare new and existing behavior as we went instead of discovering everything close to a large cutover.
We started by following a few common operations from the interface to the database and external integrations. That showed us which modules were only coupled in the code and which ones were coupled because the business process really crossed several responsibilities. The second kind was harder to separate. Moving a class to another service wouldn’t change the fact that both sides needed the same decision or shared ownership of the same data.
Production support was useful during this phase because the incidents showed paths that weren’t obvious from reading the most common use cases. We found scheduled jobs, manual corrections and older client integrations that depended on behavior not described in the current documentation. I didn’t assume every one of those behaviors had to remain forever, but we needed to know they existed before choosing to remove or replace them.
Moving It in Parts
Some responsibilities first became clearer modules inside the application. Others moved to separate services when there was a good reason for them to change or operate independently. We kept parts in Java and Spring and used Node.js and NestJS for others, based on the workload and what the teams could maintain. We weren’t trying to replace one language with another or turn every module into a microservice.
The boundaries also changed as we understood more about the system. A responsibility that looked independent on a diagram could still share data and rules with several other areas. In a few cases, separating it too early would only reproduce the same coupling through network calls, so we left it inside the application until the ownership was clearer.
One area that handled an external integration was easier to move because it already had a clear input and output, changed for reasons different from the rest of the application and benefited from processing work independently. Another area looked similar at first but read and updated data owned by several modules during the same operation. Separating that one would have required either many synchronous calls or duplicated rules, so we first improved the boundary inside the monolith.
We introduced contracts around the parts we were changing before replacing their implementations. Existing consumers could continue using the same behavior while the new path was developed behind it. This wasn’t possible everywhere, but when it was, it reduced the number of simultaneous changes and allowed us to compare results without asking every consuming team to migrate at the same time.
The database was usually the hardest boundary. Code could be moved before data ownership was clear, but then the new service remained dependent on tables managed by the old application. For some parts, we accepted that dependency temporarily. For others, we moved ownership gradually and synchronized only the information the old path still needed. Neither approach was completely clean, and we documented the temporary state because otherwise temporary dependencies have a way of becoming permanent without anyone making that decision.
The Old and New Paths
For some time, the old and new implementations had to run together. We needed to know which side owned the data, follow operations across both paths and deal with failures involving components from each architecture. It wasn’t a clean final state and it added work during the transition, but it allowed us to see each change under real use before moving the next responsibility.
In a few workflows, we compared the output from both paths before sending the new result to users. In others, we released the new implementation to a smaller group first and watched the differences. This helped us find rules that were missing from our first understanding of the module. Sometimes the new code was wrong, and sometimes the comparison showed that the old path had inconsistent behavior we could finally make explicit.
Observability mattered more during the transition because an operation could begin in the monolith, continue in a service and fail on either side. We added identifiers across the calls and logs around the boundary so support didn’t need to guess which implementation had processed a request. The system was temporarily more complex, and pretending otherwise would only have made incidents harder to investigate.
The legacy application became easier to work with through many smaller changes. We kept moving parts, finding behavior we didn’t know about and adjusting the next change with what we learned from the previous one. Deployment complexity decreased and some changes required less coordination between teams.
It also changed how we planned the work. A modernization task could include feature development, production support and investigation of behavior that wasn’t documented anywhere. Estimating it only from the amount of code we expected to write didn’t work very well. We learned to leave room for the existing system to disagree with our first plan, because it usually had more history than the ticket described.
The team still delivered normal product changes during the modernization. We couldn’t reserve every release for architecture work, so some improvements were made while a feature already touched the relevant area. Other changes needed their own project because the migration crossed data, contracts and several consumers. Balancing both kinds of work kept the modernization moving without asking the business to wait for an undefined moment when the platform would be considered finished.
A complete rewrite can make sense for some systems, but it didn’t match what we had at that time. We had a difficult codebase, behavior that still mattered and a platform that couldn’t stop while we replaced it. The incremental work was less exciting than starting an empty repository, but it gave us room to be wrong without putting the complete system at risk.
By the end of the period I worked on it, the old system hadn’t disappeared and I don’t think that should be the only measure of a modernization. Several responsibilities were clearer, parts could be deployed with less unrelated work and the team knew more about where the difficult dependencies really were. We improved the system while it continued serving its users, which was the constraint that shaped almost every decision we made.