DEV Community

Alex Aslam
Alex Aslam

Posted on

Mine Artisan's Workshop: Evolving Service Objects into Policies, Forms, and Queries

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:

  1. Decision Logic: "Can this user perform this action?" (Policies)
  2. Data Aggregation Logic: "What data do I need for this view?" (Queries)
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 User model'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
Enter fullscreen mode Exit fullscreen mode

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)