1 May 2025 (updated: 2 May 2025)
Chapters
Legacy Rails apps can be painful — messy code, outdated gems, no tests. But there’s hope. Let’s break down the real-world problems and how smart devs tackle them.
Maintaining applications is a major challenge for developers. Building an application is often more enjoyable when the code is minimal, organized, and clean. However, as applications evolve and new features are added, the codebase can become messy, and there may not always be time to tidy it up. So, what are the common problems with legacy applications and how do you manage them?
A common challenge in maintaining legacy Ruby on Rails applications is the frequent absence of comprehensive documentation, especially regarding the business logic. This lack of clarity can make understanding and modifying the code incredibly difficult.
Good practice is to use end to end (e2e) tests also for featured documentaries. Challenge is to write tests that are both good quality and are easy to understand, and that they reflect the actual behavior of the application.
Another way to replace documentation is to establish common naming, where the same terminology is used consistently in both the business domain and the codebase (Ubiquitous Language which is a core concept in Domain-Driven Design). Imagine that you have a feature which has a different name for the UI and in the application code. Sounds like discomfort for communication with the business, doesn't it? And also make difficulties for new developers for feature understanding. Shared vocabulary clarifies communication between developers and users, and ensures that the code accurately reflects the business concepts it represents.
Outdated versions of Ruby and Rails can expose your application to known security vulnerabilities. It is essential to regularly update your Ruby and Rails versions to ensure that your application remains secure. Running on older versions that are no longer supported means that you may miss critical security patches and updates that could protect your app from potential exploits.
Keep libraries and dependencies up-to-date. Not only Ruby and Rails themselves, but also any third-party libraries (gems) you're using. Outdated gems may also have vulnerabilities, and it's important to stay current with their updates.
Check for supported versions. Ensure that the Ruby and Rails versions in use are still supported and actively receiving security patches. Both Ruby and Rails follow a release cycle, with older versions eventually reaching end-of-life (EOL) status. If your versions are unsupported, it's important to upgrade to a newer version that is actively maintained.
You can also automate security testing. Adding automated security scans into your CI/CD pipeline is critical. Tools like Brakeman (for Rails-specific security checks), Bundler-audit (to scan for vulnerabilities in gems), and Snyk (for dependency vulnerabilities) help catch security issues early in the development process.
By keeping your Ruby, Rails, and gems up-to-date and integrating security testing into your development workflow, you significantly reduce the risk of running into security issues down the line.
In some cases you want or have to change dependency that you are using in the app. Good practice is not to spread dependencies throughout the application, but build a facade to communicate with the library.
One of the common problems of legacy applications. This absence of reliable tests makes refactoring and adding new features risky, as it's difficult to ensure changes haven't introduced regressions.
Prioritize writing tests for the most critical and complex parts of the application first, focusing on core business logic and commonly used features. This targeted approach provides the most immediate benefit.
When updating existing features or refactoring existing code, first you should check if code is covered with tests, and tests are testing functionality properly. If there are no tests, you should add them before starting refactoring.
You might consider using test coverage tools like SimpleCov to monitor and improve test coverage (but remember that test coverage 99% does not ensure testing of all functionalities and does not tell that applications tests are good quality).
To improve the quality of tests, it's crucial to keep them small and focused, ensuring each test covers a single aspect of the code. Tests should also be readable, with clear, descriptive names and comments where necessary to enhance understanding. Finally, avoid over-mocking, as while it can simplify testing, excessive use can create tests that don't accurately represent real-world application behavior.
Finally, adopting Test-Driven Development (TDD) for new development is a great way to ensure quality tests are written from the outset, preventing future test debt. Remember that building a robust test suite is an iterative process, and even small improvements over time can significantly enhance the maintainability and stability of a legacy application.
Performance is a key challenge in large applications, regardless of the framework. To improve slow performance in a Rails app, you need to identify bottlenecks, optimize code, and efficiently use resources. You can start by profiling your application with one of the available tools, like the rack-mini-profiler gem or New Relic to pinpoint performance issues. One common bottleneck is long-running database queries, often caused by N+1 queries. Tools like the bullet gem can help identify these, and using techniques like includes or eager_load can help preload data and eliminate the problem.
Optimizing database queries with raw SQL or advanced SQL functions, filtering and ordering data at the database level (instead of in ruby array operations or on the client side), and adding necessary database indexes will also improve performance. Caching can further boost speed by reducing response times for data that doesn't change frequently, and Redis is often used for storing quick-access data. Lastly, moving long-running tasks, like sending emails or processing files, to background jobs (using tools like Sidekiq, Resque, or Delayed Job) will prevent them from slowing down the main application.
As most Rails applications follow a similar or standardized structure, it’s easy to navigate and feel comfortable with the code when switching between projects. This convention is especially helpful for smaller applications that implement relatively simple logic. However, as the application grows in complexity, with more features and intricate business logic, the default Rails structure can make it difficult to manage and navigate the codebase.
To avoid this issue, it’s important not to dump all non-standard code into catch-all folders like app/lib or app/services. Over time, this approach leads to disorganization, making it harder to find and manage code. Instead, take the time to make deliberate architectural decisions, even if that means stepping beyond the Rails conventions.
Don’t be afraid to make changes to your app’s architecture, even if it feels like there’s never a “perfect” time to do so. As your application evolves, continuous refactoring and thoughtful restructuring will help maintain a clean and scalable codebase.
Maintaining a clean application architecture should be a primary goal for any project. In legacy applications, business logic is often scattered across services, controllers, and models, which can make the code harder to maintain. It's important to consolidate business logic in a single place, typically in service layers, to ensure better organization and scalability. Controllers should focus on handling HTTP requests and delegating tasks, while models should primarily represent data and encapsulate behavior directly related to that data.
When it comes to callbacks, many developers consider them a code smell due to their potential to obscure functionality, making the code harder to test and extend. While callbacks can be useful in some cases, they often introduce hidden side effects and complicate the application's flow. As a result, it's generally recommended to avoid them when possible and look for more explicit solutions, such as event-driven patterns or service classes, which can provide clearer and more maintainable code.
Legacy applications are often tightly coupled, which can make adding new features challenging without requiring significant modifications to the existing system. To simplify this process, one effective approach is modularization. By using strategies like service objects, presenters, or form objects, you can decouple business logic, making the application more flexible and maintainable. Another useful technique is the Strangler Fig pattern, coined by Martin Fowler, which facilitates gradual migration from old to new architectures. This pattern involves wrapping the legacy code to either redirect traffic to the newer code or log the use of the old system. Over time, the old code is gradually replaced as new features are developed in the updated style or framework, allowing for a smooth transition without disrupting the entire system at once.
One common issue with legacy applications is their inability to scale effectively. They may not have been built to handle today's traffic demands. To address this, it's essential to understand the application's scaling needs. Potential solutions range from leveraging cloud platforms with autoscaling capabilities and utilizing background jobs to optimizing database performance (which we discussed earlier). For more complex scenarios, consider breaking down the application into smaller, independent services (microservices or SOA).
The increasing size of the app/models directory in a large Rails application creates two key problems: it becomes hard to find specific models, and it becomes nearly impossible to get a clear picture of the application's core domain because important models are mixed with less important ones. As teams grow or developers change, understanding the domain logic and finding where certain code lives becomes cumbersome in a disorganized system.
To avoid being overwhelmed by numerous model files, a best practice is to organize them into sub-folders using namespaces. This approach enhances navigability and brings the most important models into clearer focus, even though the overall number of files remains the same.
By adopting a proactive approach to refactor, update, and improve the codebase incrementally, maintaining legacy RoR applications becomes a more manageable process. Maintaining legacy Rails applications can be challenging, but hopefully, these tips will prove helpful.
2 May 2025 • Ula Kowalska
24 April 2025 • Patrycja Paterska