Form Objects vs Service Objects in Rails
When building a SaaS with Rails, I often have more complex logic that spans multiple models and actions. Over time, I’ve settled on two distinct patterns: Form Objects and what I’ll just call classes (or POROs). They serve different purposes, and knowing when to reach for each has made my code easier to reason about and test.
Let me walk you through how I use them both.
Use Form Objects for user input
I use Form Objects when I have a form that a user interacts with directly, and that form needs to create (or update) more than one record. The key is the direct mapping to the form itself.
A Form Object should “quack” like an Active Record model. It validates input, it responds to #save, and it works seamlessly with Rails’ form helpers. Here’s an example:
# app/models/signup.rb
class Signup < ApplicationForm
attribute :name, :string
attribute :email_address, :string
attribute :password, :string
attribute :terms, :boolean, default: false
attribute :receive_product_updates, :boolean, default: false
normalizes :email_address, with: -> { it.strip.downcase }
validates :name, :email_address, :password, presence: true
validates_format_of :email_address, with: URI::MailTo::EMAIL_REGEXP
validates :password, length: 8..128
validates :terms, acceptance: { message: "and privacy policy need to be accepted" }
def save
if valid?
transaction do
@user = create_user.tap { it.setup_workspace(member_name: name).save }.tap do |user|
user.create_preferences.tap do |preferences|
preferences.update receive_product_updates: receive_product_updates
end
send_welcome_email_to user
end
end
end
end
attr_reader :user
private
def create_user = User.create!(email_address: email_address, password: password)
def send_welcome_email_to(user) = WelcomeEmailJob.perform_later user.email_address
end
See the inherited ApplicationForm? That is what makes most of the “quacking” possible (iirc, I got this approach from the Layered Rails Design book):
class ApplicationForm
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Attributes::Normalization
include ActiveModel::Validations::Callbacks
def model_name
ActiveModel::Name.new(self, nil, self.class.name.sub(/Form$/, ""))
end
def transaction(&block) = ActiveRecord::Base.transaction(&block)
end
In the controller, it works just like you’d expect:
class SignupsController < ApplicationController
def new
@signup = Signup.new
end
def create
@signup = Signup.new(signup_params)
if user = @signup.save
start_new_session_for user
redirect_to root_url
else
redirect_to new_signups_path
end
end
private
def signup_params; end
end
The Form Object has all the logic needed to handle the signup flow. Validations, record creation, side effects live in one place that mirrors the form itself.
For everything else use POROs
For logic that isn’t tied to a specific form, I just write a regular class. I avoid the term “Service Object” and the suffix “Service” altogether (personally hate the term!). Instead, I name it after what it actually does and I like to namespace them if related to another class (or ActiveRecord model).
Consider inviting a user to a workspace. This isn’t a form submission; it’s an action triggered programmatically (but it could be called from a form object). Here’s how I’d handle it:
# app/models/member/setup.rb
class Member::Setup
def initialize(workspace:, user:, role: "member", name: "Unknown")
@workspace = workspace
@user = user
@role = role&.to_sym
@name = name
end
def save
ActiveRecord::Base.transaction do
create_member.tap do |member|
add_roles_to member
mark_workspace_current
create_profile_for member
end
end
end
private
def create_member = Member.create(workspace: @workspace, user: @user)
def add_roles_to(member)
roles.each do |role_name|
member.actors.create role: Role.where(name: role_name).first_or_create
end
end
def mark_workspace_current = @user.update!(workspace: @workspace)
def create_profile_for(member) = member.create_profile(name: @name)
def roles
{
administrator: %w[administrator],
member: %w[member],
owner: %w[administrator billing member owner]
}[@role] || []
end
end
Notice the naming: Member::Setup. No “Service” suffix, just the thing it does, namespaced under the model it primarily concerns.
This class isn’t invoked directly. Instead, I add a method to the Workspace model:
class Workspace < ApplicationRecord
has_many :members, dependent: :destroy
def add_member(to:, role: nil, name: nil)
Member::Setup.new(workspace: self, user: to, role: role, name: name).save
end
end
Now the API is clean and expressive:
workspace.add_member User.first
workspace.add_member User.find(2), role: "administrator", name: "Alice"
Make JavaScript your second favorite language
One place to rule them all
I keep everything in app/models. No app/services folder and or app/forms. This keeps my project structure flat and makes it obvious that these are domain logic, not infrastructure.
Here is a simple table to summarize this, already short, article. 😅
| Aspect | Form objects | POROs |
|---|---|---|
| When | Direct response to user input via a form | Business logic not tied to a form |
| Naming | Match the form (Signup, ProjectInvite) |
What it does (Member::Setup, Invoice::Generate) |
| Return value | Always responds to #save; typically returns an AR model |
Depends on use case; could be an object or true/false
|
| Invocation | Directly from controller | From other classes or model methods |
| Location | app/models/ |
app/models/, namespaced as needed |
This approach gives me clarity about what each class does and when to reach for it. It is just enough organisation. Form Objects handle forms and their side effects. Classes handle everything else. Both live where they belong, named clearly and no ugly suffixes required.
Want to read me more?
-
Add Sign Up to Rails 8' Authentication
Rails 8's Authentication generator does not come with sign ups or registrations. But it isn't too hard to add yourself! This article describes a way by using a Form Object -
Add invite to Rails 8 authentication
Rails 8 authentication generator is a great start for the basics. This article explores how to add an invitation system that is ready for any business logic. -
Limit Spam Sign Ups in Rails Apps
Sooner than later your Rails app will see spam and bot sign ups. Here are three steps to minimize those sign ups from the day you launch.
Over to you…
What did you like about this article? Learned something knew? Found something is missing or even broken? 🫣 Let me (and others) know!
Comments are powered by Chirp Form
{{comment}}