I have often heard this: vanilla Rails can only get you so far. At some point, apps become unmaintainable, and you need a different approach that brings the missing pieces, because Rails encourages a poor separation of concerns at the architectural level.
The seminal Domain Driven Design (DDD) book discusses these four conceptual layers: presentation, application, domain, and infrastructure. The application layer implements business tasks by coordinating work with the domain layer. But Rails only offers controllers and models: models include persistence via Active Record, and Rails encourages direct access to them from controllers. Critics argue that the application, domain, and infrastructure layers inevitably merge into a single mess of fat models. Indeed, alternatives always include additional buckets, such as services or use case interactors, at the application layer, or repositories, at the infrastructure layer.
I find this discussion fascinating because here at 37signals we are big fans of both vanilla Rails and Domain Driven Design. We don’t run into the alleged maintenance problems when evolving our apps, so here I would like to discuss how we organize our application code.
We don’t distinguish application and domain layers
We don’t separate application-level and domain-level artifacts. Instead, we have a set of domain models (both Active Records and POROs) exposing public interfaces to be invoked from the system boundaries, typically controllers or jobs. We don’t separate that API from the domain model, architecturally speaking.
We care a lot about how we design these models and the API they expose, we just find little value in an additional layer to orchestrate the access to them.
In other words, we don’t default to create services, actions, commands, or interactors to implement controller actions.
Controllers access domain models directly
We are fine with plain CRUD accesses from controllers for simple scenarios. For example, this is how we create boosts for messages and comments in Basecamp:
class BoostsController < ApplicationController
def create
@boost = @boostable.boosts.create!(content: params[:boost][:content])
end
But more often, we perform these accesses through methods exposed by domain models. For example, this is the controller to select the desired box for a given contact in HEY:
class Boxes::DesignationsController < ApplicationController
include BoxScoped
before_action :set_contact, only: :create
def create
@contact.designate_to(@box)
respond_to do |format|
format.html { refresh_or_redirect_back_or_to @contact, notice: "Changes saved. This might take a few minutes to complete." }
format.json { head :created }
end
end
Most of our controllers use this approach of accessing models directly: a model exposes a method, and the controller invokes it.
Rich domain models
As opposed to anemic domain models, our approach encourages building rich domain models. We think of domain models as our application API and, as a guiding design principle, we want that one to feel as natural as possible.
Because we like to access business logic via domain models, some core domain entities end up offering much functionality. How do we avoid the issues related to the dreaded fat model problem? With two tactics:
- Using concerns to organize model’s code.
- Delegating functionality to additional systems of objects (AKA using plain object-oriented programming).
I’ll clarify with an example. A core domain entity in Basecamp is a Recording
. Most elements a user manages in Basecamp are recordings — the original use case that motivated Rails’ delegated types.
You can do many things with recordings, including copying them to other places or incinerating them. Incineration is the term we use for “deleting data for good”. For the caller — e.g., controller or job — we want to offer a natural API:
recording.incinerate
recording.copy_to(destination_bucket)
But, on the inside, incinerating data and copying are pretty different responsibilities. So we use concerns to capture each:
class Recording < ApplicationRecord
include Incineratable, Copyable
end
module Recording::Incineratable
def incinerate
# ...
end
end
module Recording::Copyable
extend ActiveSupport::Concern
included do
has_many :copies, foreign_key: :source_recording_id
end
def copy_to(bucket, parent: nil)
# ...
end
end
I wrote an article about how we use concerns here, if you are interested.
Now, incineration and copying are involved operations. Recording
wouldn’t be a good spot to implement those. Instead, it delegates the work itself to additional systems of objects.
For incinerating, Recording::Incineratable
creates and executes a Recording::Incineration
, which encapsulates the logic of incinerating a recording:
module Recording::Incineratable
def incinerate
Incineration.new(self).run
end
end
For copying, Recording::Copyable
creates a new Copy
record.
module Recording::Copyable
extend ActiveSupport::Concern
included do
has_many :copies, foreign_key: :source_recording_id
end
def copy_to(bucket, parent: nil)
copies.create! destination_bucket: bucket, destination_parent: parent
end
end
Here, things are more complex: Copy
is a child of Filing
. Filing
is a common parent class for both the copy and move operations. When a filing is created, it enqueues a job that will eventually invoke its #process
method. That method invokes file_recording
, a template method to be implemented by child classes. When implementing that method, Copy
creates a Recording::Copier
instance to perform the copy.
module Recording::Copyable
extend ActiveSupport::Concern
included do
has_many :copies, foreign_key: :source_recording_id
end
def copy_to(bucket, parent: nil)
copies.create! destination_bucket: bucket, destination_parent: parent
end
end
class Copy < Filing
private
def file_recording
Current.set(person: creator) do
Recording::Copier.new(
source_recording: source_recording,
destination_bucket: destination_bucket,
destination_parent: destination_parent,
filing: self
).copy
end
end
end
class Filing < ApplicationRecord
after_create_commit :process_later, unless: :completed?
def process_later
FilingJob.perform_later self
end
def process
# ...
file_recording
# ...
end
end
This example is not as simple as the incineration one, but the principle is the same: rich internal object models hidden behind high-level APIs on domain models. This doesn’t mean we always create additional classes to implement concerns, far from it, but we do when complexity justifies it.
Along with concerns, this makes the approach of classes with a large API surface work. If you are thinking about the Single Responsibility Principle (SRP), as Michael Feathers says in Working effectively with Legacy code you must differentiate between SRP violations at the interface level or the implementation level:
The SRP violation we care more about is violation at the implementation level. Plainly put, we care whether the class really does all of that stuff or whether it just delegates to a couple of other classes. If it delegates, we don’t have a large monolithic class; we just have a class that is a facade, a front end for a bunch of little classes and that can be easier to manage.
In our example, there are no fat models in charge of doing too many things. Recording::Incineration
or Recording::Copier
are cohesive classes that do one thing. Recording::Copyable
adds a high-level #copy_to
method to Recording
’s public API and keeps the related code and data definitions separated from other Recording
responsibilities. Also, notice how this is just good old object orientation with Ruby: inheritance, object composition, and a simple design pattern.
On a final note, one could argue that these three are equivalent:
recording.incinerate
Recording::Incineration.new(recording).run
Recording::IncinerationService.execute(recording)
We don’t believe they are: we strongly prefer the first form. On one side, it does a better job of hiding complexity, as it doesn’t shift the burden of composition to the caller of the code. On the other, it feels more natural, like plain English. It feels more Ruby.
What about services?
One of the building blocks of DDD is services, which are meant to “capture important domain operations that can’t find a natural home in a domain entity or value object”.
We don’t use services as first-class architectural artifacts in the DDD sense (stateless, named after a verb), but we have many classes that exist to encapsulate operations. We don’t call those services and they don’t receive special treatment. We usually prefer to present them as domain models that expose the needed functionality instead of using a mere procedural syntax to invoke the operation.
For example, this is the code for signing up a new user in Basecamp via invitation tokens:
class Projects::InvitationTokens::SignupsController < Projects::InvitationTokens::BaseController
def create
@signup = Project::InvitationToken::Signup.new(signup_params)
if @signup.valid?
claim_invitation @signup.create_identity!
else
redirect_to invitation_token_join_url(@invitation_token), alert: @signup.errors.first.message
end
end
end
class Project::InvitationToken::Signup
include ActiveModel::Model
include ActiveModel::Validations::Callbacks
attr_accessor :name, :email_address, :password, :time_zone_name, :account
validate :validate_email_address, :validate_identity, :validate_account_within_user_limits
def create_identity!
# ...
end
end
So instead of having a SigningUpService
in charge of the “signing up” domain operation, we have a Signup
class that lets you validate and create an identity in the app. One can argue this is just a bit of syntax sugar away from being a service or even a form object. But, as I see it, it’s just plain object orientation with Ruby to give a domain concept a proper representation in code.
Also, we don’t make a big deal of distinguishing whether a domain model is persisted or not (Active record or PORO). From the business logic consumer’s point of view, that’s irrelevant, so we don’t capture the distinction between domain entities and value objects in code. They are both domain models to us. You can find many POROs in our app/models
folders.
The dangers of isolating the application layer
My main problem with the idea of an isolated application layer is that people often take it way too far.
The original DDD book warns about the problem of abusing services:
Now, the more common mistake is to give up too easily on fitting the behavior into an appropriate object, gradually slipping towards procedural programming.
And you can find the same advice in Implementing Domain Driven Design:
Don’t lean too heavily toward modeling a domain concept as a Service. Do so only if the circumstances fit. If we aren’t careful, we might start to treat Services as our modeling “silver bullet.” Using Services overzealously will usually result in the negative consequences of creating an Anemic Domain Model, where all the domain logic resides in Services rather than mostly spread across Entities and Value Objects.
Both books discuss the challenges of isolating the application layer, starting with the nuances of differentiating domain and application services. Furthermore, they acknowledge that most layered DDD architectures are relaxed, with the presentation layer sometimes accessing the domain layer directly. The original DDD book states that what enables DDD is the crucial separation of the domain layer, noting that some projects don’t make a sharp distinction between the user interface and the application layers.
However, in the Rails world, you often see dogmatic takes that advocate against controllers directly talking to models with tremendous conviction. Instead, there should be an intermediary object to mediate between both — e.g. an application service from DDD or an interactor from Clean Architecture. I believe such nuance-free recommendations favor the appearance of either:
- Tons of boilerplate code because many of these application-level elements simply delegate the operation to some domain entity. Remember that the application layer should not contain business rules. It just coordinates and delegates work to domain objects in the layer below.
- An anemic domain model, where the application-level elements are the ones implementing the business rules, and domain models become empty shells carrying data around.
These approaches are often presented as a tradeoff-free answer to a very complex problem: how to design software properly. They often imply that good architecture happens as a consequence of using a discrete set of archetypes, which is not only very naive but incredibly misleading for the inexperienced audience. I hope that the alternative I presented here resonates with people looking for more pragmatic alternatives.
Conclusion
In our experience, this approach with vanilla Rails results in maintainable large Rails applications. As a recent example, we just launched Basecamp 4 built on top of Basecamp 3, the codebase of which is almost 9 years old, includes 400 controllers and 500 models, and serves millions of users every day. I don’t know if our approach would work at Shopify scale, but I am sure it would for most businesses using Rails out there.
Our approach reflects one of the Rails’ doctrine pillars: No one paradigm. I love architectural patterns, but a recurring problem in our industry is that people get very dogmatic when translating those into code. I think the reason is that simple strict recipes are very appealing when tackling a problem as complex as software development. 37signals’ code is the best I’ve seen in my career, and I say that as a spectator since I haven’t written most of it. In particular, it is the best incarnation of DDD principles I’ve seen, even if it doesn’t use most of its building blocks.
So if you abandoned the vanilla Rails path and now you are wondering if you really need those additional boilerplate classes whenever you need to handle some screen interaction, be sure that there is an alternative path that won’t compromise the maintainability of your application. It won’t prevent you from having to know how to write software — no alternative will — but it might bring you back to happy territory again.
Thanks to Jeffrey Hardy for his valuable feedback as I wrote this article. He’s one of the main contributors to this approach of architecting vanilla Rails applications I’ve come to learn and love.
Sign up to get posts via email,
or grab the RSS feed.