Loading...

Extracting validations from ActiveRecord model

Extracting validations from ActiveRecord model

The ActiveRecord pattern used in Ruby on Rails is breaking single responsibility principle really much. If you have 2 fields without complicated validation logic and 2-3 methods there is no problem with that.

When your application grows, and your models do the same, you’re getting `fat model` which is usually pretty hard to test. I prefer to break AR model into few parts. Extracting validations is the first thing I’m trying to do.

What do I get from it?

My favourite benefit, I’m getting from it, is easier testability of my models. If want to test method that depends only on 1 field, you can create an instance of your model with only that field. You don’t need to construct a model with all 15 valid fields just for testing one of them. Of course, you have to remember to run validator before any create/update operation on the object, but you’re probably smart enough to do so.

Extracting validations get rid of mutating the state of a validated model object too. Saving validation errors in model instance breaks SRP and is considered bad practice. After extraction, you don’t do it anymore.

The other great benefit of that is an architecture that allows you to easily provide different validations for different contexts. For example, in your application admin panel, you’ll need different validations than in user panel. If you want to achieve it, you just create another validator class and provide it in the correct place.

How does it look before anything?

Let’s assume, that we build simple blogging platform and we have 2 models: User and Post

app/models/user.rb

class User < ApplicationRecord
  has_many :posts
 
  validates :first_name, presence: true
  validates :last_name, presence: true
  validates :age, presence: true, numericality: { only_integer: true }
  validates :nickname, presence: true, length: { in: 3..20 }
 
  def published_posts_count
    posts.where(published: true).count
  end
end

app/models/post.rb

class Post < ApplicationRecord
  belongs_to :user
 
  validates :title, presence: true
  validates :body, presence: true
end

and of course specs for user:

specs/models/user_spec.rb

require 'rails_helper'
 
RSpec.describe User, type: :model do
  let(:first_name) { 'Bartosz' }
  let(:last_name) { 'Bonislawski' }
  let(:nickname) { 'BBDev' }
  let(:age) { 18 }
  let(:subject) do
    described_class.create!(first_name: first_name,
                            last_name: last_name,
                            nickname: nickname,
                            age: age)
  end
  it { is_expected.to validate_presence_of(:first_name) }
  it { is_expected.to validate_presence_of(:last_name) }
  it { is_expected.to validate_presence_of(:age) }
  it { is_expected.to validate_presence_of(:nickname) }
  it { is_expected.to validate_numericality_of(:age) }
  it { is_expected.to validate_length_of(:nickname, in: 3..20) }
 
  describe '#published_posts_count' do
    context 'with 1 published post' do
      let!(:post) { subject.posts.create!(title: 'title', body: 'body', published: true) }
 
      it 'returns 1' do
        expect(subject.published_posts_count).to eq 1
      end
    end
 
    context 'with unpublished post' do
      let!(:post) { subject.posts.create!(title: 'title', body: 'body', published: false) }
 
      it 'returns 0' do
        expect(subject.published_posts_count).to eq 0
      end
    end
  end
end

As you can see, if we want to test the simple method for counting published posts of a user, we have to build whole valid User instance and valid Post instances. These are really simple models, but imagine a situation where they had 10+ fields and all had custom validations. It’d be a pain to build objects like that and you’d probably have to use Factory Girl gem or something like that. I consider it as a bad thing when you need to install some gem to build objects when you don’t really need them to have all fields filled.

How to fix stuff

Let’s start with creating UserValidator class. I use virtus gem to add attributes to POROs and active model validations for validating part. We have to define all attributes that will be validated and then define validations for them. Actually, you just copy them from your model. Our UserValidator should look something like:

app/validators/user_validator.rb

class UserValidator
  include Virtus.model
  include ActiveModel::Validations
 
  attribute :first_name, String
  attribute :last_name, String
  attribute :nickname, String
  attribute :age, Integer
  attribute :published, Boolean
 
  validates :first_name, presence: true
  validates :last_name, presence: true
  validates :age, presence: true, numericality: { only_integer: true }
  validates :nickname, presence: true, length: { in: 3..20 }
end

And now some specs for that:
specs/validators/user_validator_spec.rb

require 'rails_helper'
 
RSpec.describe UserValidator, type: :validator do
  it { is_expected.to validate_presence_of(:first_name) }
  it { is_expected.to validate_presence_of(:last_name) }
  it { is_expected.to validate_presence_of(:age) }
  it { is_expected.to validate_presence_of(:nickname) }
  it { is_expected.to validate_numericality_of(:age) }
  it { is_expected.to validate_length_of(:nickname, in: 3..20) }
end

If you run specs now, you’ll get a fail because you need to load validators first. Add loading validators to your application.rb:

config/application.rb

config.paths.add File.join('app', 'validators'), glob: File.join('**', '*.rb')

The next step of extraction, is to add our validators to places where we create/update our User. Your actions in controllers could like something like:

app/controllers/users_controller.rb

  def create
    validator = UserValidator.new(user_params)
 
    if validator.valid?
      user = User.create!(user_params)
      render :show, status: :created, location: user
    else
      render json: validator.errors, status: :unprocessable_entity
    end
  end
 
  def update
    validator = UserValidator.new(user_params)
    user = User.find(params[:id])
 
    if validator.valid?
      user.update(user_params)
      render :show, status: :created, location: user
    else
      render json: validator.errors, status: :unprocessable_entity
    end
  end

After you’ve done everything, we can do best thing ever: delete some code! Let’s remove validations from our model so it looks like that now:
app/models/user.rb

class User < ApplicationRecord
  has_many :posts
 
  def published_posts_count
    posts.where(published: true).count
  end
end

and a little bit updated specs:

specs/models/user_spec.rb

require 'rails_helper'
 
RSpec.describe User, type: :model do
  let(:subject) { described_class.create! }
 
  describe '#published_posts_count' do
    context 'with 1 published post' do
      let!(:post) { subject.posts.create!(title: 'title', body: 'body', published: true) }
 
      it 'returns 1' do
        expect(subject.published_posts_count).to eq 1
      end
    end
 
    context 'with unpublished post' do
      let!(:post) { subject.posts.create!(title: 'title', body: 'body', published: false) }
 
      it 'returns 0' do
        expect(subject.published_posts_count).to eq 0
      end
    end
  end
end

As you can see, we don’t need to create a valid model. Actually, we don’t even have something like ‘valid model’. Every instance is valid. If you won’t forget about calling your validator, you won’t get problems with invalid objects in your database.

If we do something like that with Post model too, we’ll get rid of passing params to creating posts and we will be able to do something like

let!(:post) { subject.posts.create! }

Summary

After extracting validation part to separate class, we can create model instances easily without providing all the data(that we don’t even need!). In this example, it’s pretty small improvement, because the code was readable. The more complex application you have, the improvement will be bigger.

Feel free to leave comment and questions! In one of the upcoming posts, I’ll share with you alternative approach to validations in ruby: `hanami-validations` based on `dry-validation`.
If you like it, you can follow me on twitter/facebook or subscribe to push notifications(red mark in the left down corner of the page).

Leave a Reply

Social media & sharing icons powered by UltimatelySocial