Practical Lessons from Maintaining a 10-Year-Old Rails Service
Introduction
New technologies and the latest frameworks are always exciting, but in reality, most engineers spend much of their careers maintaining large, complex, and sometimes very old applications. The nearly 20-year-old Rails app at Harvest is a prime example of this reality.
In this post, I want to share practical lessons on long-term software maintenance—combining insights from Julia López’s talk, “Beyond the Hype: Practical Lessons in Long-Term Rails Maintenance” and my own experience developing and maintaining a large-scale web service that has been running for 10 years, five of which I spent as a core engineer.
1. What Does Long-Term Software Maintenance Really Mean?
The Scope of Maintenance
Software maintenance goes far beyond simply keeping an app running. It involves managing technical debt, refactoring, updating dependencies, ensuring security, implementing new features, and fixing bugs—all crucial activities for any mature system.
My Personal Experience
I’ve been working on the codebase of a large-scale web service now in its tenth year, and for the past five years I’ve been directly involved in its development. Over this time, the most significant change has been the separation of dozens of features into microservices. Still, the core Ruby on Rails codebase continues to handle 20 million monthly active users (MAU). Consistently upgrading the system to Rails 8 and Ruby 3.4 has been one of our most meaningful accomplishments.
2. Technical Debt & Refactoring: A Realistic Approach
The Inevitability of Technical Debt
Even in the 20-year history of Harvest’s app, technical debt has been a key issue. The older a system gets, the more technical debt it accumulates—and how you manage this debt becomes critical to long-term sustainability.
Impact on Development Speed (From Experience)
Technical debt directly affects how quickly you can deliver new features. A common issue is running into code that wasn’t designed to scale or is difficult for others to understand. Sometimes, when a feature (A) unexpectedly succeeds and you need to expand to B, C, and D, you realize the underlying database or architecture can’t keep up and must be reworked.
Another challenge is simply understanding the existing logic. Even with simple requirements, deciphering legacy code can be time-consuming—especially when responsibilities between domains aren’t clear, leading to “spaghetti code” problems.
The Importance and Approach of Refactoring
The talk introduced a useful refactoring process:
First, use exploratory days to investigate problem areas. Next, conduct spikes—quick, focused experiments to validate concepts. Finally, approach refactoring incrementally and systematically.
A mindset that finds enjoyment in refactoring also helps tremendously. Regularly deleting dead code keeps the codebase clean and healthy.
Looking Back at Old Code (Personal Experience)
There have been many times I’ve looked at code I wrote years ago and thought, “Did I really do it this way?” Sometimes even code I wrote yesterday feels strange the next day; after five years, the feeling is magnified.
In these moments, it’s important to remember: “It might have been right then, but it may be wrong now.” Priorities change depending on the service stage, and what looks like a code smell today might have been essential for the service’s survival back then.
Technical Debt vs. Feature Development: Practical Prioritization
There’s always debate among teammates, but I believe this is ultimately clear: If your company or team isn’t yet on a sustainable trajectory, delivering value to users and finding PMF (Product-Market Fit) comes first—technical debt can wait.
Even with 20 million MAU, I still see our service as not “there yet.” Most users are domestic, and our global impact is still limited. As services grow, codebase stability becomes more important, but not at the expense of user value.
When cascading issues occur, there’s no universal answer. Personally, I believe teams responsible for a service should prioritize immediate requirements, cleaning up technical debt afterward. The problem, of course, is that “afterward” often never comes—something organizations need to address systematically.
Making the call between paying down debt and building new features is always tough. If it’s a new feature, speed matters; if the debt seriously hinders productivity, it’s worth tackling. Ultimately, it’s case by case.
Technical Debt and Scalability (From Experience)
When aiming for global expansion or more growth, existing technical debt can hold you back. For small-scale global services, SaaS solutions are often feasible, but for domestic services with high traffic and cost concerns, they’re not always an option.
When a database overhaul is needed, I try not to be intimidated—I view it as something we can do with determination, even if it means adding columns, dual-writing data, and later backfilling.
3. The Reality of Microservice Separation: Between Theory and Practice
Many organizations consider moving from a monolith to microservices, but my experience has shown there’s often a huge gap between theory and practice.
Criteria for Separation and Team Ownership
In our case, microservice separation was done by giving teams ownership over specific features. More precisely, we split off features that weren’t core to the team’s mission, such as platform-level or company-wide services like image processing and authentication.
The Rails monolith remained as the core of the business, while other functions were moved out as microservices.
Real-World Challenges in Separation
Truly separating domains within a service turned out to be much harder than expected. Communication overhead was significant, and sometimes separation wasn’t clean—leaving some features awkwardly split, or still served from the monolith despite database separation.
The root cause was differences in how teams saw the scope of services. More importantly, our focus was often on offloading load from the monolith rather than achieving clear domain separation, leading to half-complete migrations stuck at the database level.
Key Lesson from Microservice Migration
The most important lesson: Be clear about why you’re splitting things out. If you decide to separate, ensure that related keywords and logic are truly removed from the monolith.
Don’t split services by inertia—align everyone on the rationale for the change. Without this, migrations take too long and rarely deliver satisfying results.
4. Upgrading Rails/Ruby: Changing the Wheels on a Moving Train
Keeping Rails and Ruby up to date in a large-scale service is never easy.
The main reason we managed to keep our 20-million MAU service on Rails 8 and Ruby 3.4 was the ongoing interest and commitment of teammates. Raising awareness that framework and language upgrades are as important as feature development was key.
The Importance of a Dedicated Team
Having a dedicated platform team focused on improving the monolith was a major boost. Although they often supported microservice migration, giving them both the mission and time to work on core improvements made all the difference.
Interestingly, Rails upgrades and microservice migration didn’t conflict. In fact, deleting old code made upgrades easier, and migration mostly involved explaining old logic and context—not a huge resource drain.
Organizational Understanding and Support
Fortunately, our leadership understood the value of this work—seeing “changing the wheels on a moving train” as critical groundwork for moving faster in the future.
Challenges in the Upgrade Process
With a decade-old codebase, the hardest problems weren’t gem compatibility or deprecated features—those can be fixed with enough effort. The real challenge was code so old that nobody could tell if it was still in use or truly needed. Sometimes, we had to run logging for long periods just to see if a method was still called.
5. Dependency Management: Consistent Attention Required
Regularly managing and updating dependencies (gems, libraries, etc.) is essential for security and stability. It’s also important to remove dependencies made obsolete by Rails improvements.
At Harvest, Renovate is used to systematically manage upgrades, and Dependabot covers security patches. When developing new features, it’s recommended to update dependencies proactively. Tools like bundle outdated
are also helpful.
6. Team Dynamics and Shared Maintenance Responsibility
Maintenance isn’t just for a specific team or person—it’s a shared responsibility for the whole engineering team. The “Boy Scout Rule” (always leave the code better than you found it) is a simple but powerful principle for ongoing improvement.
When planning projects, it’s crucial to allocate enough time for maintenance work, especially with old codebases where unexpected complexity often arises. PMs and engineers need to collaborate closely to assess both complexity and feasibility.
7. Integrating Maintenance into the Workflow
Pull Requests (PRs) and code reviews are essential moments for routine maintenance. Code reviews are a chance to check for updated dependencies and suggest improvements.
Always write detailed PR descriptions—for the benefit of your future teammates, or even your future self. Reliable tests are crucial; they give you confidence in code changes and releases.
Maintain good test coverage, eliminate flaky tests, and keep your CI fast and healthy.
Build out your CI/CD pipeline and use feature flags to enable safe, frequent deployments. Have a rollback plan for incidents, and use observability tools (e.g., DataDog, Grafana) to monitor your applications after deployment.
8. Refactoring Core Logic with Confidence: The Scientist Gem
When refactoring core logic, being able to compare results in production is a game changer. The Scientist gem (developed by GitHub) lets you run both your “control” (old) and “candidate” (new) code paths and compare the results safely.
Harvest used Scientist for things like updating authorization logic, comparing SQL queries, and evaluating whether caching could be removed. Setting up a good system for tracking and analyzing experiment results (e.g., saving to Redis, integrating with Grafana) is essential.
Conclusion
Successfully maintaining an old Rails application is just as important—and often even more strategic—than developing new features.
Diligent maintenance and continuous learning from past experience are the foundation of effective engineering.
Long-term maintenance succeeds when individual effort (“Boy Scout Rule,” detailed PRs, etc.) combines with team-level systems and culture (shared responsibility, planning, tool adoption).
One key lesson from my microservices journey: Always clarify your reason for separation, and align all stakeholders. Don’t split things out just for the sake of splitting; do it with purpose, and remove all related logic from the monolith.
Combining Julia López’s insights and my five years growing a 10-year-old large-scale web service, I hope these ideas will help you think about not just maintaining old applications, but growing them over time.
Ultimately, I believe that a culture of sustainable engineering—rooted in steady improvement and lifelong learning—is what truly powers services that stand the test of time.
References
- López, Julia. “Beyond the Hype: Practical Lessons in Long-Term Rails Maintenance” (2024). Available at: https://www.youtube.com/watch?v=uublj1pAkOg