A change-positive Ruby web application architecture
A look into a post-Rails, post-MVC world
A driving theme in our discussions so far has been the importance of assembling your code so that future change becomes easy. We want to enable sustainable development, to keep a steady, positive pace, from day 1 all the way past day 1,000.
Status quo, status oh-no
My first steps towards more sustainable development happened once I finally started looking beyond Rails and MVC. Rails’ implementation of MVC gives you a couple of large buckets to throw your code into and little else. And what it does offer you comes with some significant long-term risks.
Let’s start with the “M” portion, synonymous with ActiveRecord in a Rails app. ActiveRecord models have too much responsibility: data modelling, persistence, validation, type coercion, your own domain logic, and this is before one of the many Rails-focused gems extends things even further. With models responsible for so much, it follows that they become important to use across all parts of your application. Then, with ActiveRecord’s API being so broad, your entire application winds up coupled to details of your database schema. And that API is available for you to use anywhere, too: want to write to arbitrary parts of your database from within a view template? Go right ahead!
ActiveRecord works like this by design; Rails wants to offer convenience above all else. But this convenience ends up saddling every application with a poor arrangement of responsibilities, right from the start. It means that you effectively write your app from inside its database adapter. This is true of working in Rails in general: you’re not writing a Ruby application, you’re writing your app inside of Rails. Rails is your key architectural choice, and everything follows from that.
And it’s Rails’ subsequent lack of architectural handholds that is the main issue here. Now that we’ve looked at the models, what’s left? Rails has views and controllers, but the views are just template files, so all we’re really left with are the controllers. With just models and controllers as the only framework-provided places to hold your code, this is where most of the code in common Rails apps will accrete. On the side of the models, you have the details of your database leaking everywhere, and from the controller side, you have HTTP details leaking everywhere. And Rails gives you no hint about how to architect a clean app in between.
If you’ve been through this trauma before, you might realise that wishful thinking isn’t enough and start laying down some of your own high-level architecture within Rails, using something like Trailblazer or domain-driven design techniques. However, it would take considerable discipline to pull this off from inside a framework that still sits there offering all manner of conveniences that might help you out of a tight spot now but only spell trouble later on.
It’s the “trouble later on” that’s really the rub here. I won’t argue that Rails can’t offer productivity when you’re getting started, but it fails to offer anything to sustain this productivity once you move past this phase. Rails apps tend to become harder and harder to change over time.
A welcome change
What we want instead is an architecture that prepares us for all stages of an app’s lifecycle. We want a change-positive web application architecture.
Over the last few weeks, we’ve identified a few important traits of change-positive apps. They should:
- Be composed of many small pieces, tightly focused, loosely coupled.
- Offer a facility to make it simple to combine these small pieces into larger, more complex ones.
- Make the flow of data through your system easy to follow, by using functional objects for transformations and respecting immutability everywhere else.
This is just the beginning. There are several important elements left:
Our app should stand alone. It should be a Ruby app first, web app second.
Our app should respect its boundaries, the points at which it integrates with other significant systems. For web apps, these will be the incoming HTTP requests and usually a database.
We should choose appropriate tools to manage the work across these boundaries: pick an HTTP router and place it in front of our app’s main components, find a persistence library and configure it to expose our database in the way we need.
Our app should then provide its own distinct interfaces around these boundaries. These interfaces should mediate the data coming across these boundaries and shape them into forms that are sensible to use within our app’s own core components.
In short, we should use tools in service of our app, not the other way around.
Our app’s core high-level components should be oriented around verbs, not nouns. A web app is really just a series of actions that can be invoked by its users. Abstractions like controllers may make sense to group 1-7 actions together and offer some code reuse between them, but when we already have other options for reuse, this is no longer necessary. A single-focused, standalone component modelled as a verb is easier to change than a controller that does 7 different things.
Indeed, as well as doing as little as possible, each of our app’s components should know as little as possible about the world outside. Each component should have just enough information, just enough access to other objects (with APIs that are as narrow as possible), as it needs to serve its function. Any more and we’re creating an unnecessary coupling that will impede future change.
Finally, in building our apps we should strive for simple, not easy. Simple allows us to clearly follow how components interact, and how data move from place to place. Simple allows us to adjust any of these when we need. Easy puts too much of these out of sight, into places beyond our direct control. We should stay mindful of opportunities for abstraction, of course, but never rush into them.
Follow these principles, and we’ll make apps that are both a pleasure to build and change across all stages of their development!
Over the next weeks we’ll take these principles and couch them in some real, working code. We’ll start by looking at Roda and how it can offer you a clear separation between the world of HTTP and the rest of our app.
Read part 1 of this series: My past and future Ruby