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
Published by Rails Designer. Buy it today.

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.

Product-minded Rails notes

Once a month: straightforward notes on improving UX in Rails—what to simplify, what to measure, and UI/frontend changes that move real usage.

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

Want to read me more?