DEV Community

Lucas M.
Lucas M.

Posted on • Edited on

Streamlining Rails Controllers with Simple PORO Validators

Disclaimer: This article proposes a straightforward approach to request validation in Rails controllers. While it doesn't claim to be a perfect solution for every scenario, it presents a simple pattern that can be applied to many cases and help simplify controller responsibilities.

Observation

When dealing with complex Rails controllers, finding an elegant and maintainable way to perform validations on incoming requests can be challenging, be it to simply check the presence of mandatory attributes or to deal with more intricate business-logic checks.

Let’s consider this basic controller as an example:

class SimpleController < ApplicationController
  def perform_computation
    service = MyBusinessLogicService(
      param1: params[:param1],
      param2: params[:param2],
      param3: params[:param3]
    ).new
    result = service.perform

    respond_to do |format|
      format.json { render json: result }
      format.html { render text: result.to_s }
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

It defines a simple action that calls a business-logic service and returns the result.

Now, suppose we need to perform various validations, of different natures:

  • Check that param1 and param2 are present
  • Check that the current user has the permission to perform this action, depending on its role (RBAC)
  • Check that the current user has an attribute active set to true

Given the nature of the validations at stake here (input-related validation, session-related validation, and business-logic validation), we are likely to come to the conclusion that the controller is not the ideal place to perform them.

The controller's primary role should focus on instantiating/updating/deleting objects, invoking services or libraries containing business logic, and formatting output data before rendering.

Disclaimer: In real-life cases, parameter validation and authorisation checks are likely to be split and/or factored out of the controllers. I am including them in the same validator here for the sake of the example, by lack of more business logic checks.

The validator pattern

Instead of cluttering the controller action with validation-related code, let’s define the following ‘validator’ class:

class RequestValidator
  class UnauthorizedError < StandardError; end
  class InactiveUserError < StandardError; end

  def initialize(param1:, param2:, param3:, user:)
    @param1 = param1
    @param2 = param2
    @param3 = param3
    @user = user
  end

  def call!
    validate_required_params!
    validate_user_role!
    validate_user_is_active!
  end

  private

      def validate_required_params!
        raise ParameterMissing, :param1 if @param1.blank?
        raise ParameterMissing, :param2 if @param2.blank?
      end

      def validate_user_role!
        raise UnauthorizEderror, 'User not allowed to perform this action' unless RoleChecker(user: user).allowed?
      end

      def validate_user_is_active!
        raise InactiveUserError, "User is not active" unless @user.active?
      end
end
Enter fullscreen mode Exit fullscreen mode

Instead of having a bloated controller, we can now plug validations in a very light fashion:

class SimpleController < ApplicationController
  def perform_computation
    validate_computational_request!

    service = MyBusinessLogicService(
      param1: params[:param1],
      param2: params[:param2],
      param3: params[:param3]
    ).new
    result = service.perform

    respond_to do |format|
      format.json { render json: result }
      format.html { render text: result.to_s }
    end

  rescue RequestValidator::UnauthorizedError => e
    render json: { error: e.message }, status: :unauthorized
  rescue RequestValidator::InactiveUserError => e
    # Some custom logic
    # ...
  end

  private

    def validate_computational_request!
      RequestValidator.new(
        param1: params[:param1],
        param2: params[:param2],
        param3: params[:param3],
        user: current_user
      ).call!
    end
end
Enter fullscreen mode Exit fullscreen mode

Principle / Benefits

  • PORO Object: The validator is implemented as a Plain Old Ruby Object (PORO), avoiding dependencies and complex designs.
  • Single Responsibility: Each validator object has a single responsibility and a single instance method (call!), adhering to the Single Responsibility Principle.
  • Separation of Concerns: The validation logic is clearly separated from the business logic, improving maintainability and readability.
  • Straightforward Error Handling:
    • Sequential validations allow each step to raise specific errors with more informative context, aiding in debugging.
    • Namespaced errors enable targeted exception handling, whether in individual actions or parent controllers. This can help logging relevant information prior to rendering custom messages.
  • Modularity and Reusability: Validator classes can be easily reused across different controllers or actions, promoting code reuse and consistency.
  • Improved Testability: With validations isolated in separate objects, unit testing becomes more focused and efficient.
  • Reduced Controller Complexity: By moving validations out of controllers into dedicated objects, we achieve thinner, more manageable controllers.

This approach to request validation using simple PORO objects offers a clean, maintainable way to handle complex validation scenarios in Rails applications, leading to more organised and scalable codebases.

Top comments (3)

Collapse
 
railsdesigner profile image
Rails Designer • Edited

Interesting approach.

I like to make a distinction between "service"- and "form"-objects.

  • service objects take some value (from another class, an api, etc.)
  • form object work off of input value from the user (ie. via an actual form)

A form object is a simple class that "quacks" like an ActiveModel model:

class SignupForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :email, :string
  attribute :password, :string

  validates :email, :password, presence: true

  def save
    if valid?
      User.create!(email: email, password: password)
      # etc.
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

This distinction helps reason about what is what and should be stored where.

Collapse
 
lucianghinda profile image
Lucian Ghinda

I think it is also worth taking a look at this gem dry-rb.org/gems/dry-operation/1.0/ could simplify the creation of the validation object in case you don't want to raise but just to return an error.

Collapse
 
lcsm0n profile image
Lucas M.

I didn't know this gem, thanks for sharing it, I'll give it a try!
I feel like this is a bit closer to an "Interactor" pattern though, which is an interesting way of seeing validations.

Another possible way of performing validations within the pattern described here without raising errors is to use ActiveModel::Errors in your validator class, and call .valid? on it from the given controller. This way, you can stick to plain dependency-free Rails classes :)