There comes a time in every senior Rails developer's journey when the app/services directory becomes a refuge for our guilt. That complex UserRegistrationService we wrote six months ago? It's now a 300-line monolith that handles validation, sends three different emails, applies promotional codes, and writes to an analytics database. We extracted it from the controller, yes, but we've merely created a new, more sophisticated god object.
We were told to "use service objects," so we did. But we missed the deeper truth: not all procedural logic is the same. A blacksmith who uses only a hammer will create functional, but clumsy, artifacts.
It's time to evolve our craft. It's time to move beyond the basic "Service Object" and build an Artisan's Workshop, filled with specialized tools, each with a single, exquisite purpose.
The Philosophy: Separating the Chisel from the Hammer
The fundamental insight is that the logic we casually toss into a "service" actually falls into three distinct categories:
- Decision Logic: "Can this user perform this action?" (Policies)
- Data Aggregation Logic: "What data do I need for this view?" (Queries)
- Command Logic: "Perform this complex business transaction." (Forms & Commands)
When we mix these concerns, we create a brittle, hard-to-test system. By separating them, we create a system that is not just organized, but expressive. Let's tour the workshop.
The Policy: The Workshop's Rulebook
In the corner of our workshop sits the Policy. This is not a controller before_action with a tangled mess of if statements. It is a simple, focused object whose sole job is to answer a yes/no question about permissions.
The Problem: Scattered authorization logic.
# In a controller... a classic smell.
def update
@article = Article.find(params[:id])
if current_user.admin? || (current_user == @article.user && !@article.published?)
# ... update logic
else
redirect_to root_path, alert: "You can't do that!"
end
end
The Policy Solution: A dedicated rulebook object.
# app/policies/article_policy.rb
class ArticlePolicy
attr_reader :user, :article
def initialize(user, article)
@user = user
@article = article
end
def update?
user.admin? || (user == article.user && !article.published?)
end
end
# In the controller, the intent is crystal clear.
def update
@article = Article.find(params[:id])
authorize_article(:update?) # A helper method that uses ArticlePolicy
if @article.update(article_params)
redirect_to @article
else
render :edit
end
end
The Policy is a thing of beauty. It's easily testable in isolation, and it declaratively encodes a business rule. It's the workshop's constitution, ensuring order and clarity.
The Query Object: The Master Cartographer
Next, we meet the Query Object. Our views often need complex, scoped data. Placing this logic in a scope or the controller bloats the model and obfuscates intent.
The Problem: A complex scope or a bloated controller.
# In the controller... what does this even *mean*?
@dashboard_articles = Article.published
.includes(:comments, :author)
.where("view_count > ?", 100)
.where(authors: { status: :active })
.order(published_at: :desc)
.limit(10)
The Query Solution: A dedicated cartographer.
# app/queries/dashboard_articles_query.rb
class DashboardArticlesQuery
def self.call
Article.published
.includes(:comments, :author)
.where("view_count > ?", 100)
.where(authors: { status: :active })
.order(published_at: :desc)
.limit(10)
end
end
# The controller becomes a story, not a query plan.
def dashboard
@articles = DashboardArticlesQuery.call
end
The Query Object doesn't just hide complexity; it gives it a name. DashboardArticlesQuery is a domain concept. It's easily reusable and its internals can evolve (adding caching, changing the includes) without touching the controller. It's the map that guides your data to the view.
The Form Object: The Master Orchestrator
Finally, we arrive at the heart of the workshop: the Form Object (or Command Object). This is the evolution of the classic "Service Object." Its job is not to be a dump for procedural code, but to orchestrate a single business transaction, especially one that involves multiple models.
The Problem: The "God" Service Object.
# app/services/user_registration_service.rb
class UserRegistrationService
def self.call(user_params, invite_code = nil)
# Validate user_params
# Check invite_code
# Create the user
# Apply promotional credit from the invite
# Send a welcome email
# Send an admin notification
# Log the event
# ... all in one long, procedural mess.
end
end
The Form Solution: A focused orchestrator that delegates to smaller, single-purpose objects.
# app/forms/user_registration_form.rb
class UserRegistrationForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :email, :string
attribute :name, :string
attribute :invite_code, :string
# ... more attributes
validates :email, presence: true, format: URI::MailTo::EMAIL_REGEXP
def save
return false if invalid?
ActiveRecord::Base.transaction do
create_user!
apply_promotional_credit! if invite_code.present?
log_registration_event!
end
# These are outside the transaction as they are side-effects
send_welcome_email!
send_admin_notification!
true
rescue => e
errors.add(:base, "Registration failed: #{e.message}")
false
end
private
def create_user!
@user = User.create!(email: email, name: name)
end
def apply_promotional_credit!
# This could be its own Service Object!
CreditApplicationService.call(user: @user, code: invite_code)
end
def send_welcome_email!
UserMailer.welcome(@user).deliver_later
end
def send_admin_notification!
AdminNotificationService.new_user_registered(@user).deliver_later
end
def log_registration_event!
AnalyticsService.record(event: 'user_registered', user: @user)
end
end
The Form Object is the masterpiece of the workshop. It:
- Encapsulates a use case: "Register a User."
- Handles its own validation: It's not tied to the
Usermodel's validations, which might be different for registration. - Manages the transaction boundary: It knows what operations must be atomic.
- Orchestrates collaborators: It delegates to other services (
CreditApplicationService,AnalyticsService), respecting the Single Responsibility Principle.
It's not a god object; it's a conductor, leading a symphony of specialized instruments.
The Masterpiece: A Cohesive System
Let's see the whole workshop in action for a PurchaseCourse action.
# In the Controller
def create
@form = PurchaseCourseForm.new(user: current_user, course_id: params[:course_id])
# 1. The Policy checks permission
unless CoursePolicy.new(current_user, @form.course).purchase?
redirect_to courses_path, alert: "You cannot purchase this course."
return
end
# 2. The Form Object orchestrates the transaction
if @form.save
# 3. The Query Object fetches the data for the next view
@user_courses = PurchasedCoursesQuery.call(current_user)
redirect_to dashboard_path, notice: "Course purchased!"
else
render :checkout
end
end
Each object has a clear, single responsibility. The controller is a thin, readable coordinator. The system is a joy to reason about and test.
The Artisan's Mandate
As senior developers, our goal is not to avoid complexity, but to manage it with elegance and precision. The basic Service Object was a first step, a rejection of the "Fat Model, Skinny Controller" dogma. But we must go further.
Stop building workshops with only a hammer. Start crafting the specialized tools:
- Reach for a Policy when you need to make a decision.
- Reach for a Query when you need to find data.
- Reach for a Form when you need to perform a complex command.
Build a system that speaks the language of your domain, not the language of your framework. This is the path from a codebase that merely works to one that is truly, profoundly well-crafted.
Top comments (0)