An ActiveRecord Adapter

Extracting side effects like ‘save’ and ‘find’ from AR models

Following my RailsConf 2015 talk What Comes After MVC, I want to address the number-one question I’ve gotten: What does an ActiveRecord model look like when you pull the side effects out into an Adapter? (Page 62 and 63 of the talk slides.)

If you want the link to the presentation slides again, it’s: /uploads/2015/04/What-Comes-After-MVC-Peter-Harkins-RailsConf-2015.pdf

Let’s dig into an example. I’ve grabbed the User model from the sample application in Michael Hartl’s Rails Tutorial. It looks pretty typical:

https://github.com/mhartl/sample_app/blob/master/app/models/user.rb https://www.railstutorial.org/

class User < ActiveRecord::Base
  attr_accessible :name, :email, :password, :password_confirmation
  has_secure_password
  has_many :microposts, dependent: :destroy
  has_many :relationships, foreign_key: "follower_id", dependent: :destroy
  has_many :followed_users, through: :relationships, source: :followed
  has_many :reverse_relationships, foreign_key: "followed_id",
                                   class_name: "Relationship",
                                   dependent: :destroy
  has_many :followers, through: :reverse_relationships, source: :follower

  before_save { |user| user.email = user.email.downcase }
  before_save :create_remember_token

  validates :name,  presence: true, length: { maximum: 50 }

  VALID_EMAIL_REGEX = /A[w+-.]+@[a-zd-.]+.[a-z]+z/i

  validates :email, presence: true, format: { with: VALID_EMAIL_REGEX },
                    uniqueness: { case_sensitive: false }
  validates :password, length: { minimum: 6 }
  validates :password_confirmation, presence: true

  def following?(other_user)
    relationships.find_by_followed_id(other_user.id)
  end

  def follow!(other_user)
    relationships.create!(followed_id: other_user.id)
  end

  def unfollow!(other_user)
    relationships.find_by_followed_id(other_user.id).destroy
  end

  def feed
    Micropost.from_users_followed_by(self)
  end

  private

    def create_remember_token
      self.remember_token = SecureRandom.urlsafe_base64
    end
end

The only thing that stand out to me about this model is that it’s short, less than 50 lines. Most ActiveRecord models I see in my consulting are several hundred to low thousands of lines of code long.

I can look at this model and understand everything it’s doing now, but that understanding will vanish when the model grows. Extracting the parts that have
side effects means I can treat the model as an entity, a predictable component of my business domain.

So here’s what I’d leave the model looking like:

class User < ActiveRecord::Base
  attr_accessible :name, :email, :password, :password_confirmation

  has_secure_password
  has_many :microposts, dependent: :destroy
  has_many :relationships, foreign_key: "follower_id", dependent: :destroy
  has_many :followed_users, through: :relationships, source: :followed
  has_many :reverse_relationships, foreign_key: "followed_id",
                                   class_name: "Relationship",
                                   dependent: :destroy
  has_many :followers, through: :reverse_relationships, source: :follower

  after_initialize :create_remember_token

  validates :name,  presence: true, length: { maximum: 50 }

  VALID_EMAIL_REGEX = /A[w+-.]+@[a-zd-.]+.[a-z]+z/i

  validates :email, presence: true, format: { with: VALID_EMAIL_REGEX },
                    uniqueness: { case_sensitive: false }
  validates :password, length: { minimum: 6 }
  validates :password_confirmation, :remember_token, presence: true

  def email= new_email
    self[:email] = new_email.downcase
  end

  private

  def create_remember_token
    self.remember_token ||= SecureRandom.urlsafe_base64
  end
end

I don’t want to pull out all the has_many calls into some module that User includes. I think that’s too clever by half, it makes it harder for me to know how User relates to the rest of the system with no benefit.

Most of the User methods have been extracted because they existed to abstract away the relationships collection. We’ll look at where they went in a moment, but first I want to talk about the two before_save callbacks I deleted.

The unnamed before_save block callback is now a setter. This is a minor change, but if it’s important for the email to be lowercased it should always be lowercased, not just before it’s saved in the database. This leads to those situations where a test mysteriously starts passing when you change FactoryGirl.build to FactoryGirl.create. And there’s that tiring dilemma, “well, it works now, but now the test takes half a second instead of a hundredth of a second – would it be worth the time for me to diagnose and fix this?” Then you repeat that for the next few dozen tests and soon every test has to use .create and your suite takes 10 minutes to run.

The create_remember_token callback is still there, with two changes. First, it now runs after_initialize instead of before_save. I would prefer to create the token in an initializer, but those are so easy to get wrong in ActiveRecord models that I prefer to use their after_initialize callback. I want objects to always be in a valid state and using after_initialize means no object can see this User when it doesn’t have a token. I also added a presence validation: before_save ran after validation, so this wasn’t possible before.

Last, I tweaked create_remember_token to use   = to allow pre-filling a token. Previously, the token was always generated, so it was impossible to duplicate all the fields of a records. This little impossibility will lurk for months until you want to duplicate an object for another system or a test, then you will have a frustrating surprise you’ll have to dig to understand.

Next up, let’s look at where the association code went:

class Follows
  attr_reader :user

  include Adamantium

  def initialize user
    @user = user
  end

  def following?(other_user)
    user.relationships.find_by_followed_id(other_user.id)
  end

  def follow!(other_user)
    user.relationships.create!(followed_id: other_user.id)
  end

  def unfollow!(other_user)
    user.relationships.find_by_followed_id(other_user.id).destroy
  end
end

class Feed
  attr_reader :user

  include Adamantium

  def initialize user
    @user = user
  end

  def microposts
    # extracting side effects from Micropost is an obvious next step
    Micropost.from_users_followed_by(self)
  end
end

I can guess what you’re thinking: “Same garbage, more cans. Now I have to dig through three files to find my code.”

Anyone familiar with ActiveRecord will find this code a little unusual but not exceptional. This isn’t your my User model split between three files, this is one file for each concept that was mashed into your User model: the User, the Follow relationships with other users, the Feed. The code isn’t split up because some rule says to split it up, the rule leads us to find the boundaries we were missing. That’s where I’m taking the design: we write code that rewards a next step forwards from what we already know. We don’t take leaps into the unfamiliar that blame us when we can’t follow vague principles.

Coming back to the structure I outlined in the talk: now the ActiveRecord model is an entity. Testing it doen’t mean using FactoryGirl to save Relationships and Microposts. The Follows and Feed classes have the side effects but no state. They’re easy to test with mocks or integrated against the database because they don’t have logic.

In this small sample app the User doesn’t have custom business logic in it, but I’ll bet your User model does. When the logic that connects your models also talks to the database we have a knot we can never untie because it’s impossible to predict what’s happening when. The database is a global variable holding our code down.

To finish this benefit of splitting side effects ouf of the ActiveRecord model, there’s one more class to write. I looked through the codebase to find the points where the User class or .users relationships are used by some other class digging into the database and wrote them an interface:

class UserRepo
  attr_reader :user

  include Adamantium

  def initialize user
    @user = user
  end

  def save
    @user.save
  end

  def destroy
    @user.destroy
  end

  def self.by_id(id)
    User.find(id)
  end

  def self.by_remember_token(token)
    User.find_by_remember_token(token)
  end
end

(Yes, this can be written a lot shorter with Forwardable from the standard library, but I wanted to make this first example obvious.)

In this small sample app there’s a limited number of places that look up User models in a limited number of ways. But I bet your app isn’t like that. I bet a thousand things look up Users, and not just through scopes but by directly calling User.where(active: true, type: :paid, …). Maybe you’ve written enough scopes and finder methods to keep outside classes from knowing the meanings of database columns in the users table, but ActiveRecord makes it very easy to slip on this.

When we follow the rule of keeping side effects out of entities we have to crate the UserRepo class to encapsulate them. This isn’t “same garbage, more cans” because we see immediate benefits in models that are solely about our problem domain. Now there’s one class with the responsibility of looking up users from the database, that knowledge doesn’t smear across the entire app.

More importantly as the app grows, we have control over the lifecycle of the ActiveRecord model. I mentioned this briefly on slide 17 of the talk and I’ll can go into more detail in a future email, so let me know if you want this sooner rather than later.

I hope this worked example helps you start transforming your messy models into understandable entities. My goal with understanding immutability and side effects in our code is to find that next step. It doesn’t have to be perfect, it just has to help us write and understand better code.

I hate that feeling of working on a two-year old app and thinking: “That’s the third time I’ve been burned by underestimating how long a simple feature would take. I can only daydream about a Next Generation App we’ll rewrite from scratch with… [spins the redesign roulette wheel] …microservices. I guess that’s the ticket.”