Advanced Form Objects in Rails
A brief into to an advanced use case for Form Objects in Ruby on Rails.
There will come a time in every Rails developer’s journey when the MVC and ‘fat controller, skinny model’ approach just ins’t cutting it. It could be a complex billing service, an object that touches multiple models or a complex form. The good news is that when these situations arise, we have more tools at our disposal as Rails engineers. One of those tools is form objects. We can use form objects to help simplify complex forms using ‘Rails-y’ patterns and the ActiveModel::Model module. Lets take a look!
At their core, form objects are just classes. They’re built in such a way that they feel like active record objects but can be more flexible to suit your needs. Let’s imagine a scenario together. You are building a CRM for a plumbing business. There is a Customer model, a Job model, a Message model and a Payment model. In order to submit feedback on a job, a customer must have completed payment for a job. The customers/messages#new controller action will be responsible for displaying a form to allow a customer to submit a message to the company, again assuming that they have completed payment for a job. Then the customers/messages#create action will be responsible for running validations and then creating the Message record if the validations passed. Let’s take a look at how we may build this without a form object.
1class Customers::Messages < ApplicationController 2 before_action :authenticate_user! 3 before_action :set_customer 4 5 def new 6 @message = Message.new 7 end 8 9 def create 10 # Check to see if the customer has a completed payment for a job 11 unless @customer.payments.where(status: :completed).exists? 12 @message.errors.add :base, "You must have a completed payment to send a message" 13 render :new, status: :unprocessable_entity 14 end 15 16 if @message.save 17 redirect_to customer_message_path(@customer, @message) 18 else 19 render :new, status: :unprocessable_entity 20 end 21 end 22 23 def show 24 # ... 25 end 26 27 private 28 29 def set_customer 30 # ... 31 end 32end
This will do just fine, but there is a better way! We can create a form object for a new message that will take care of this validation for us. Lets take a peek at that new form object class.
1class NewMessageForm 2 include ActiveModel::Model 3 4 attr_reader :message, :customer 5 6 # Use built-in Rails validation techniques to do the validation 7 validates :text, presence: true 8 validate :customer_has_completed_payment 9 10 def initialize(text:, customer:) 11 @text = text 12 @customer = customer 13 end 14 15 def save 16 valid? && Message.create(text:) 17 end 18 19 private 20 21 def customer_has_completed_payment 22 # Here is where we perform the complex validation 23 24 unless customer.payments.where(status: :completed).exists? 25 errors.add :base, "You must have a completed payment to send a message" 26 end 27 end 28end
Notice how we can take advantage of the Rails validate method to perform that more complex validation in a Rails-y way. Now, our more advanced form object encapsulates the logic required to validate and save a new message form to the database. Now, let’s update the controller to use this new form object.
1class Customers::Messages < ApplicationController 2 before_action :authenticate_user! 3 before_action :set_customer 4 5 def new 6 @message = Message.new 7 end 8 9 def create 10 # Notice how much cleaner this is in the controller. Much easier to follow. 11 @form = MessageForm.from_params message_params, customer: @customer 12 13 if @form.save 14 redirect_to customer_message_path(@customer, @message) 15 else 16 render :new, status: :unprocessable_entity 17 end 18 end 19 20 def show 21 # ... 22 end 23 24 private 25 26 def message_params 27 params.require(:message).permit(:text) 28 end 29 30 def set_customer 31 # ... 32 end 33end
We no longer have that ugly conditional at the top of our #create action performing some low level checks. We can treat the form object like we would a regular ActiveRecord backed model. As you progress in Rails, you’ll find that #create actions in controllers often times take the shape of our action here. As your application grows, business logic should be pushed into other objects to allow for greater readability, re-usability, etc.
You’ll notice a new method .from_params being called on the form object. This is a class method that is responsible for creating a new form object from the request’s parameters. Check it out below.
1class NewMessageForm 2 # ... 3 4 def self.to_params(params, customer:) 5 # This method converts request params to a valid form object 6 7 new( 8 text: params[:text], 9 customer: 10 ) 11 end 12 13 # ... 14end
This just allows for even more re-usability because the form object itself knows how to instantiate itself from standard Message model params. You can now use this in another controller without writing any more code to handle new message form logic!
You can take this even further if your use-case is more real-world. I’ll detail that in a future article soon. I’m also hoping to create a demo application and repo to show off these advanced Rails patterns. Thanks for reading! Subscribe with your email if you’d like to see more advanced Rails tips like this.