Using Rails ActiveRecord and ActiveModel Validations to simplify your controllers

ActiveRecord::Validations Module is bundled in with Rails and it provides a nice way to validate your ActiveRecord models. This functionality can also be included into ActiveModel classes, allowing you to validate your POROs (Plain Old Ruby Objects).

ActiveRecord and ActiveModel validations allow developers to simply their controllers. In this post I will work through a controller refactoring, using validations.

How do validations work?

Validations prevent you from saving in-valid data to your database. Here is a simple example of an ActiveRecord model with a validation:

class User < ApplicationRecord
  validates :name, presence: true
end

Here we have a User model with some validation to ensure that a user always has a name. ActiveRecord::Validations provides a valid? method.

User.create(name: "Tom").valid? # => true
User.create(name: nil).valid? # => false

When a user does not have a name, valid? will return false.

Validations are automatically run when you try to save an object. This allows you to write Rails controllers that look like this:

class UsersController < ApplicationController  
  def create
    @user = User.new(user_params)
    if user.save
      redirect_to @user
    else
      render "new"
    end
  end
end

If the User is valid is will run the code inside the if block. If the User record is not valid then save will fail and the controller will render the new form.

Validations vs Controller conditional logic

One of the temptations, when trying to validate params, is to put conditional logic into your controllers. Lets look at an example:

class UsersController < ApplicationController
  def create
    if params[:user][:age].to_i < 16
      render "new"
    elsif government_service.pet_age > 15
      render "new"
    end

    @user = User.create(user_params)
    if @user.save
      render 'index'
    else
      render 'new'
    end
  end

  private

  def user_params
    params.require(:user).permit(
      :name,
      :age
    )
  end

  def government_service
    GovernmentService.new(params[:insurance_id])
  end
end

In this example we are working for a pet insurance company. We want to prevent anyone signing up to the service who is under the age of 16 and we also want to check the pets age, using an external service called GovernmentService.

In our controller we first check for those two conditions. Assuming they are both satisfied, we continue to save the user object.

There are three main problems with this logic:

  1. Putting validation logic into the model is safer because it is impossible to create a User without passing the validations. Putting validation logic into a controller action is less safe because is may be possible to circumvent the validations in a different controller action (for example, if a developer forgets to copy this logic when creating a new Users controller).
  2. Validations are hard to test in controllers because you have to set up a whole end-to-end test. Model validations are much simpler and can be written as unit tests rather than end-to-end tests. This also has the advantage of making your tests faster.
  3. Validation logic in your controllers is verbose and complicated. Moving this logic to your models gives you a much more elegant solution and allows you to keep your controllers skinny.
Moving validation logic to the model

Now that we know the pitfalls of validation logic in controllers, lets refactor this UsersController to remove the validations.

  1. The first thing that we can do is remove the age check from the UsersController.
    class UsersController < ApplicationController
      def create
        if government_service.pet_age > 15
          render "new"
        end
    
        @user = User.create(user_params)
        if @user.save
          render 'index'
        else
          render 'new'
        end
      end
    
      private
    
      def user_params
        params.require(:user).permit(
          :name,
          :age
        )
      end
    
      def government_service
        GovernmentService.new(params[:insurance_id])
      end
    end
    

    We can re-write it as a validation on the User model.

    class User < ApplicationRecord
      validates :age, :numericality => { :greater_than => 15 }
    end
    

    Now, when the controller tries to save the user it will return false so we no-longer need to check the params.

  2. Next we can tackle the check on the pet’s age. In this (slightly contrived) example, we don’t actually have a model for pets; we don’t want to persist the pet’s age, we just want to check it. Because there is no model, lets create a service class to verify the pets age.
    class GovernmentValidatorService
      include ActiveModel::Validations
    
      validates_inclusion_of :age, in: 0..15
    
      attr_accessor :id, :age
    
      def initialize(id)
        @id = id
        @age = pet_age
      end
     
      def pet_age
        government_service.pet_age
      end
    
      def government_service
        GovernmentService.new(id)
      end
    end
    

    The Pet class is a PORO. We can mixin validation functionality by including ActiveModel::Validations. Once we’ve done that we can add a simple initializer and validation for the pet’s age.

  3. At this point we can replace the params check with our new Pet validator.
    class UsersController < ApplicationController
      def create
        if GovernmentValidatorService.new(params[:insurance_id]).valid?
          render "new"
        end
    
        @user = User.create(user_params)
        if @user.save
          render 'index'
        else
          render 'new'
        end
      end
    
      private
    
      def user_params
        params.require(:user).permit(
          :name,
          :age
        )
      end
    end
    

    As you can see, we are now utilising our the new PetValidationService. However, we are still doing the check inside the controller, which we are trying to avoid. Lets see if we can refactor some more.

  4. There are a number of different refactorings that we could do here. One option is to create a service/factory that is responsible for creating the patient if the pet is the right age.
    class UserFactoryService
      include ActiveModel::Validations
    
      validate :validate_new_user
      validate :validate_government_validator
    
      attr_accessor :user_params, :insurance_id, :new_user
    
      def initialize(user_params, insurance_id)
        @user_params = user_params
        @insurance_id = insurance_id
        @new_user = build_new_user
      end
    
      def save
        @new_user.save if valid?
      end
    
      private
    
      def build_new_user
        User.new(user_params)
      end
    
      def government_validator_service
        @government_validator_service ||= GovernmentValidatorService.new(insurance_id)
      end
    
      def validate_new_user
        return if @new_user.valid?
    
        add_errors_for(@new_user)
      end
    
      def validate_government_validator
        return if government_validator_service.valid?
        
        add_errors_for(government_validator_service)
      end
    
      def add_errors_for(validator)
        validator.errors.messages.each do |k, v|
          v.each do |message|
            errors.add(k, message)
          end
        end
      end
    end
    

    Now we can re-write the controller to remove the extraneous conditional logic. Instead of calling User.new directly we can now call the UserFactoryService which will handle all of the validation for us and initialize a new user instance.

    class UsersController < ApplicationController
      def new
        @user = User.new
      end
    
      def create
        @user = UserFactoryService.new(user_params, params[:insurance_id])
        if @user.save
          render 'index'
        else
          redirect_to(action: "new")
        end
      end
    
      private
    
      def user_params
        params.require(:user).permit(
          :name,
          :age
        )
      end
    end
    

Rails validations are a valuable tool in removing validation logic from your controllers. In this post I’ve shown you what Rails validations are and how to use them. Happy refactoring!