In building large Rails apps with test-driven development, I have often struggled with ways to minimize the presence of ActiveRecord objects in the tests. In an ideal world, unit tests would rarely if ever hit the database. And yet in common practice in a Rails app, every level of the testing pyramid reads from and writes to the database. The inevitable, resulting slowdown derives almost exclusively from the way Rails encourages the use of ActiveRecord objects, as if they were the only tenable currency of the realm.
Now that "fat model, skinny controller" has lost its appeal -- if you don't believe me, ask any developer who has to maintain an app with models whose line count numbers in the hundreds if not the thousands -- the question remains, what can we do to isolate ActiveRecord?
The world of domain driven design and hexagonal architecture provides some exciting alternatives. For those new to the subject, Uncle Bob has a great blog post on the subject and a recent talk at RailsConf also gives a well-presented rundown. See also, Eric Evan's book by the same name.
Among all the ideas of domain driven design, the repository pattern is especially relevant. Rather than mix our business logic and persistence needs in a single object as ActiveRecord encourages, the repository pattern forces the separation of business logic from persistence. The repository operates on entities, distinct representations of ideas from a business domain, and is charged only with persistence.
Let's talk about some code. As usual, skip to GitHub for the TL;DR. What follows are annotations to my attempt at writing a proper repository.
To start, let's assume we want to build an application to store movies. The usual approach would be to create a
Movie class in the
models directory. Instead, we will create a
daos (i.e., database access objects) directory to keep the ActiveRecord objects which know how to persist themselves to a SQL database. Here's what our
MovieDao looks like:
require "active_record" class MovieDao < ActiveRecord::Base self.table_name = :movies validates_presence_of :title, :director end
Granted, one could argue that validation is a separate concern, but overlooking that little detail, our
MovieDao follows the single responsibility principle. It is charged with writing data to the database.
Before we discuss the repository, let's take a quick look at the entity object -- what will represent the domain object. The entity will be the currency of the application, and anyone who wants to do anything with movies will operate on entities. Any changes to an entity, however, will have to pass through the repo if there are to be persisted. The entity is, in effect, nothing more than a bag of data:
require "active_model" class MovieEntity include ActiveModel::Serializers::JSON attr_reader :id, :title, :director def initialize(attributes) @id = attributes[:id] @title = attributes[:title] @director = attributes[:director] end def attributes instance_values.symbolize_keys end def persisted? !id.nil? end end
There is some boilerplate here which makes the entity play well with serialization (e.g., the JSON module) and which makes accessing the data easy for ActiveRecord (e.g., the
#attributes method). Otherwise, the entity holds only three values. Note that once we set the attributes in
#initialize, we expose them on a read-only basis.
And, finally, for the interesting part: the repository. The full code for the repo can be found here. The repo supports all the CRUD actions, but we will look only at the
#add function. Once
#add makes sense, the rest of the methods are straightforward and easy to understand.
This is what
#add looks like:
require_relative "../daos/movie_dao" require_relative "../entities/movie_entity" require_relative "../shared/store_result" require_relative "../shared/error_factory" class MovieRepository def initialize(factory = MovieFactory, dao = MovieDao) @factory = factory @dao = dao end def add(entity) record = dao.new(entity.attributes) if record.save return successful_result(record) end failed_result_with_errors(record.errors) end # ... private attr_reader :factory, :dao def successful_result(record = nil) entity = record ? factory.create(record) : nil StoreResult.new( entity: entity, success: true, errors: nil ) end def failed_result_with_errors(errors) StoreResult.new( entity: nil, success: false, errors: ErrorFactory.create(errors) ) end end
Let's start with
The repo takes two dependencies: 1) a factory to inflate entities from ActiveRecord objects (see here), and 2) a database access object, otherwise known as our familiar ActiveRecord
MovieDao object from above.
Let's walk through the
#add method in detail. First, it's worth noting that the clients of the repo are charged with passing in already inflated entities. The repo expects movie entities with a
director attribute already set. From there, we build an ActiveRecord object in memory with the entity's attributes and then attempt to save it.
It's at this moment that the repo does something interesting. If the attempt to save is successful, we do not pass back the ActiveRecord object. And for that matter, we do not pass back a raw entity, either. In order to pass back some information about the save attempt, we return a
StoreResult is an extremely simple object:
class StoreResult attr_reader :entity, :errors def initialize(entity:, success:, errors:) @entity = entity @success = success @errors = errors end def success? success end private attr_reader :success end
By wrapping our return value in a
StoreResult object, we have a clean way to return the entity, the result of the store attempt (e.g., true or false), and any errors which may be attached to the ActiveRecord object. Using
StoreResult we keep any kind of ActiveRecord object isolated to the repo layer.
This is the most important point of the repository. The ActiveRecord object is nothing more than a private implementation detail of the repository and we strictly enforce the encapsulation of that detail. By returning a
StoreResult object, we ensure that consumers of the repo receive nothing more than the entity in question, some information about the result of the attempt to persist the entity, and, if necessary, any errors related to the validity of the entity.
In producing a
StoreResult object which wraps our entity, the repo will alternatively make a call to
#failed_result_with_errors. Both methods create a
StoreResult. In case our attempt at saving the ActiveRecord object fails, we'll pass the associated errors to the
#failed_result_with_errors method which will create a basic errors object to be consumed elsewhere in the system.
One obvious benefit to the repository pattern is the way persistence becomes an implementation detail. If in the future, we have to persist our data across an HTTP request, we have to rewrite only our repository.
Another big win with using the repository is the way it frees up our need to connect to a database in test. By swapping our repository out with a fake repository which honors the same interface but otherwise lives only in memory, we can completely remove the need to hit the database in test at the unit level.
More importantly, though, by using entities and repositories in place of a single ActiveRecord object, we have honored the single responsibility principle. Persistence is the repository's responsibility, and our entity's responsibility is simply to model the behavior inherent to the domain object it represents.
Note: I'm grateful to Joe Rodriguez whose idea for a
StoreResult object I use in my repo. See his original implementation here.