Rails Monolith towards Engines spike - Our story
This year my team started working on a new UI for our patient check-in application, with slightly modified rules for specific use cases and forms. It would reuse most of our backend software with few changes. We named it Bariloche.
Bariloche is a new UI for our patient check-in workflows, targeted for Urgent Care facilities. Given that the UI would start from scratch, we’d use it as a testbed to build our frontend entirely with React Components. Everything would be namespaced under the “Bariloche” name.
Such a clear boundary between the monolith and this product made it an ideal case to try an Engine implementation. Given Bariloche would share the data layer declared by the main app and not need database migrations, our first step towards Engines would be smaller than for other potential extractions we could foresee.
Why an Engine?
An Engine architecture would enable the Bariloche team to run specs in a greenfield app without the need to run the entire suite frequently. A Rails Engine depends on the host app and not the other way around: changes in Bariloche couldn’t break the parent app. Running fewer tests would accelerate feedback and development while decreasing our CI bills.
An explicit declaration of dependencies within the app would let us see component boundaries, dependency directions, and circular dependencies more clearly, encouraging us to be more mindful when referencing classes. I noticed a few anomalies when trying to disentangle Bariloche out of the monolith and found they couldn’t have happened in an Engines architecture.
The question behind the spike: Is it worth it for us?
I didn’t know whether we were “big enough” that it’d be worth it, and the spike would shed some light on our costs and benefits equation.
My hunch was that it might be early for the change: we were ten developers, rarely blocking each other, running a healthy test suite, and delivering features at a pace that felt good for us. If it ain’t broke don’t fix it, right? But I also thought that it’s better to untangle earlier rather than later if the team deems the cost to be worth it.
Moreover, if we liked it better, we could extract other parts of the app and
further optimize our test suite runtimes, decreasing the surprise factor
of code living in an isolated Engine outside the app/
directory only for this
project.
Some implementation details
The spike, by definition, was short-lived. During those experiments, we discovered the following.
With Bariloche being a Rails application on its own right, names can’t clash with
the parent app, and for example, bariloche_completed_path
became just
completed_path
, the namespace declaring the product context of every file.
Class name clashes couldn’t happen either. The need for the ::
prefix in some
class references and related bugs disappeared. An example again with the
CompletedStep
object, that exists without prefix as a model that tracks
patient’s progress, and prefixed by Bariloche
, Previsit
, and NavigationList
for the other contexts we have. (I know, either the model or the navigation steps
should be renamed, Monday-morning quarterbacks!)
I wanted to have spec/features/bariloche
specs defined within the Engine but
didn’t manage to do it before concluding I wouldn’t move forward with this
approach.
Conclusion for our team
I started the spike seeking to accelerate our velocity with:
- Better code organization
- More explicit dependency declarations
- Inclusion only of relevant parts of our codebase for developing and testing components
- Faster spec runs and more rapid feedback on changes
We could get those benefits with an unusual architecture for us. I was one of the few developers thinking about changing how we organize our code; the change wouldn’t be natural at first for everyone.
Feature specs would naturally live in the Engine to exercise the new UI, but the shared models’ unit specs would live in the main Rails application (part of the goal was to share most backend code that was already running).
If we moved forward, Engine specific migrations (for Bariloche or another future Engine) seemed like a problem to us: we’d either have to use the main app’s DB schema or we’d have to keep them in sync. I didn’t have a satisfactory answer yet.
Finally, for faster spec runs during Bariloche development, we could run the
following command: bundle exec rspec spec/**/bariloche/*_spec.rb
.
So I closed the PR and shared what I learned with the team. The one monolith continued to be the best solution for us.
Related links:
- Scaling Your Rails App Codebase with CBRA - Ben Klang, Power Home Remodeling (YouTube)
- The Modular Monolith: Rails Architecture - Dan Manges (Medium)
- Between monoliths and microservices - Vladimir Dementyev (RailsConf 2020)
- A compilation of Component-Based Rails resources (cbra.info)
- Shopify recently released packwerk to help draw package boundaries