My last couple of jobs have both been with companies that were struggling with complex rails applications. There was nothing fundamentally wrong with the technology choices, the software had just organically become difficult to work with.
In this kind of an environment, engineers are unhappy with the technical debt, product are unhappy with the slow delivery. But it's not easy to fix.
There is a temptation to start over with a big rewrite, but rewrites take ages, and divert resources away from improving the user experience.
These are some notes I've gathered about improving the structure of these rails applications without rebuilding the system from scratch or spinning up new infrastructure.
- your rails app is turning into a big ball of mud
- you need to think about everything to change anything
- it's hard to understand what the intended structure is supposed to be.
- reduce cognitive load on engineers
- enable teams to work autonomously and take ownership of components
- this might be a preparation step for rebuilding functionality or extracting separate services
Enter the modular monolith
Modular monoliths are applications that are deployed as one service but whose code is split into loosely coupled components. This allows you to reduce levels of coupling at coding time, without having to change runtime behaviour, infrastructure etc.
Domain driven design helps establish boundaries
You can start introducing new structure by modelling the different business contexts and reflecting that in your code. Each context comes with its own special terminology. If you get together with domain experts, you can agree shared terms (a.k.a a ubiquitous language) and a shared model that both the software engineers and the domain experts understand. You can then use this to decide where to split the application.
Decide where to start
If you are starting from a monolith, then you can gradually evolve the system towards a modular monolith by
- organising code into logical components or packages
- strengthening boundaries and decoupling the components from each other
There are different ways of going about this.
Option 1: Depth first
One way is to pick out one or two candidate components to focus on, follow the steps below to decouple them from the rest of the application, and then continue component-by-component.
The advantage of this option is it doesn't require a huge up-front commitment, and you can earn buy-in from stakeholders over time. A small number of people can work on it and then present the results to the rest of the engineering team. You then have good examples for other engineers and teams to follow.
Option 2: Breadth first
An alternative is to do a very rough pass over the whole codebase, that introduces the desired structure, but accepts that the interfaces are poor and components are still tightly coupled together. You then refine the components.
The advantage of this approach is that engineers can start to benefit straight away from code being easier to navigate. But there's a risk that you never finish the work. You can mitigate this by implementing tooling to inform engineers of issues that need to be corrected, and showing them why it is important.
Steps to introduce a component
- Move the existing code, including models
- Establish a public API for the component
- Make sure all calls to the component use the public API
- Introduce an "anti-corruption layer"
- Introduce value objects for the component
- The anti-corruption layer transforms from ActiveRecord models into value objects of the component
- The public API can now be changed to accept and return value objects, not ActiveRecord models
- Prevent outside code from instantiating component models directly
- Modify calling code so you no longer need the anti-corruption layer (by using the modified, ActiveRecord-free API directly)
- Remove associations between component models and outside models
- If you depend on data outside of the component, then access it through a method call that returns a value object (avoid ActiveRecord)
- Remove foreign keys that cross the component boundary
- Prefer polymorphic associations for linkages to things outside of the component. This inverts the dependency
- (optional) This isolation can be enforced by migrating data to a separate schema
- Remove cyclical dependencies between components (Acyclic dependencies principle)
- https://github.com/Shopify/packwerk allows you to make dependencies between components explicit
- Rails engines are another way of organising a rails app into components
Additional resources on this topic
- RubyHack 2019: Taming Monoliths Without Microservices by Kelly Sutton
- The Modular Monolith: Rails Architecture
- Taming large rails applications with private ActiveRecord models
- Under deconstruction: the state of shopify's monolith
- Painless Rails without Overengineeriug
- Growing rails applications in practice
- Sustainable web development with Ruby on Rails
- Gradual modularization for Ruby and Rails
- Migrating to microservices
- Between monoliths and microservices
Top comments (0)