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
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
andparam2
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 totrue
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
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
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)
Interesting approach.
I like to make a distinction between "service"- and "form"-objects.
A form object is a simple class that "quacks" like an ActiveModel model:
This distinction helps reason about what is what and should be stored where.
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.
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 :)