Jonathan Bennett

Form Objects: A Rails-Friendly Approach

accepts_nested_attributes is a great tool when working with ActiveRecord objects. But what if you’re managing complex signup logic with multiple models, like User and Account? Enter the PORO (Plain Old Ruby Object): a clean, Rails-friendly solution for maintaining separation of concerns while keeping signup logic in one place.

For instance, you might be looking to create a signup form that manages User and Account objects:

class SignUp
	def user
		@user ||= User.new
	end

	def account
		@account ||= Account.new
	end
end

This provides basic support for handling the data. The way we want to use this in our views would be:

<%= form_with model: @sign_up do |form| %>
	<%= form.fields_for :user do |user_fields| %>
		<%= user_fields.email_field :email_address %>
		<%= user_fields.password_field :password %>
	<% end %>

	<%= form.fields_for :account do |account_fields| %>
		<%= account_fields.text_field :name %>
	<% end %>

	<%= form.submit %>
<% end %>

And in our controllers:

class SignUpsController < ApplicationController
	def new
		@sign_up = SignUp.new
	end

	def create
		@sign_up = SignUp.new(sign_up_params)
		@sign_up.save!

		redirect_to welcome_path
	end

	def sign_up_params
		params.require(:sign_up).permit(
			user_attributes: [:email_address, :password],
			account_attributes: [:name]
		)
	end
end

To make this work, we need to make some small changes to the SignUp class:

class SignUp
	include ActiveModel::Model

	def user
		@user ||= User.new
	end
	
	def user_attributes=(attributes)
		user.assign_attribute(attributes)
	end

	def account
		@account ||= Account.new
	end
	
	def account_attributes=(attributes)
		account.assign_attribute(attributes)
	end

	def save!
		ActiveRecord::Base.transaction do
			user.save!
			account.user = user
			account.save!
		end
	end
end

By including ActiveModel::Model, the SignUp class gains access to Rails features like form validations and naming conventions. This ensures it integrates seamlessly into the Rails ecosystem, allowing for clean views and controller logic. For instance, it automatically connects to sign_ups_controller and supports helpers like new_sign_up_path.

Wrapping the save! logic in a transaction ensures both the User and Account objects are saved together. If either save fails, the entire operation is rolled back, preventing partial updates.

This approach lets you cleanly keep all the signup logic in one place, separate from your general User and Account concerns.