Most Rails tutorials teach layered architecture as if every app needs it from day one. They don’t. A 15-controller CRUD app with service objects, query objects, form objects, and presenters is over-engineered. The real skill is knowing when your codebase crosses the threshold where layers pay for themselves.

This guide covers what each layer does, when to introduce it, and when to leave it out.

The Four Layers and What They Actually Do

LayerResponsibilityRails mappingIntroduce when…
PresentationRender output, handle user inputViews, serializers, React/VueAlways present
ApplicationOrchestrate workflowsControllers, service objectsControllers exceed ~30 lines
DomainBusiness rules, invariantsPlain Ruby objects, ActiveModelModel files exceed ~200 lines
InfrastructurePersistence, APIs, jobsActiveRecord, Sidekiq, adaptersAlways present

The key constraint: dependencies flow downward only. A controller can call a service object. A service object can call a model. A model should never call a controller or reference a view.

app/
├── controllers/          # Application Layer
├── models/               # Domain Layer
├── services/             # Application/Domain Layer
├── queries/              # Infrastructure Layer
├── views/                # Presentation Layer

Layered Rails Design with Vladimir Dementyev

Rails

When Fat Models Become Unmaintainable: The 500-Line Threshold

Rails encourages “fat models, skinny controllers.” That advice works until a model hits roughly 500 lines. At that point you’ll notice:

  • Unrelated callbacks tangled together. An after_save that sends a notification lives next to one that updates inventory.
  • Test files that take minutes to run. Every test loads the full model with all its concerns, validations, and associations.
  • Merge conflicts on every PR. Three developers touching the same Order model for unrelated features.

The fix isn’t to immediately extract everything into service objects. Start by identifying clusters of behavior:

# Before: 600-line Order model with mixed responsibilities
class Order < ApplicationRecord
  # pricing logic (~80 lines)
  # fulfillment logic (~120 lines)
  # notification logic (~60 lines)
  # validation logic (~100 lines)
  # scopes and queries (~80 lines)
  # callbacks (~40 lines)
end

Extract the largest cluster first. Fulfillment logic spanning 120 lines is a good candidate for OrderFulfillmentService. Pricing rules that change with business requirements belong in a PricingCalculator. Scopes go into a query object.

Don’t extract everything at once. One extraction per PR, with tests migrated alongside.

Service Objects That Actually Work

Most service object tutorials show a trivial example and call it done. Real service objects need a consistent interface, error handling, and composability.

Pick a convention and stick with it. The two most common:

  1. Plain Ruby with a call method returning a result struct
  2. interactor gem (or dry-transaction) for multi-step workflows

Here is option 1 with a lightweight result object:

class ProcessOrder
  Result = Struct.new(:success?, :order, :error, keyword_init: true)

  def initialize(order_params, user)
    @order_params = order_params
    @user = user
  end

  def call
    order = Order.new(@order_params.merge(user: @user))

    unless order.valid?
      return Result.new(success?: false, error: order.errors.full_messages.join(", "))
    end

    ActiveRecord::Base.transaction do
      order.save!
      ChargePayment.new(order).call
      ScheduleShipment.new(order).call
    end

    Result.new(success?: true, order: order)
  rescue PaymentError => e
    Result.new(success?: false, error: "Payment failed: #{e.message}")
  end
end

The controller stays thin:

class OrdersController < ApplicationController
  def create
    result = ProcessOrder.new(order_params, current_user).call

    if result.success?
      redirect_to order_path(result.order)
    else
      flash.now[:alert] = result.error
      render :new, status: :unprocessable_entity
    end
  end
end

When to use the interactor gem instead: If your workflows have 4+ steps with rollback logic, the interactor gem gives you an organize DSL and built-in rollback hooks. For simpler 2-3 step flows, plain Ruby is less overhead.

Query Objects: Extract When Scopes Multiply

Query objects earn their keep when a model accumulates 8+ scopes, or when a single query spans multiple tables with conditional filters.

class ProductSearch
  def initialize(params = {})
    @params = params
  end

  def call
    scope = Product.includes(:category, :brand)
    scope = apply_price_filter(scope)
    scope = apply_availability_filter(scope)
    scope = apply_category_filter(scope)
    scope = apply_sort(scope)
    scope.page(@params[:page]).per(25)
  end

  private

  def apply_price_filter(scope)
    scope = scope.where("price_cents >= ?", @params[:min_price] * 100) if @params[:min_price]
    scope = scope.where("price_cents <= ?", @params[:max_price] * 100) if @params[:max_price]
    scope
  end

  def apply_availability_filter(scope)
    return scope unless @params[:in_stock]

    scope.where("inventory_count > 0")
  end

  def apply_category_filter(scope)
    return scope unless @params[:category_id]

    scope.where(category_id: @params[:category_id])
  end

  def apply_sort(scope)
    case @params[:sort]
    when "price_asc"  then scope.order(price_cents: :asc)
    when "price_desc" then scope.order(price_cents: :desc)
    when "newest"     then scope.order(created_at: :desc)
    else scope.order(:name)
    end
  end
end

Skip query objects when: You have fewer than 5 scopes on a model, or the queries are simple where chains. Rails scopes are fine for that.

USEO’s Take: When Layered Architecture Becomes Overkill

We maintain Rails apps ranging from 8-controller internal tools to 200+ model e-commerce platforms. Here is what we have learned:

For apps under 20 controllers, we skip the full layered approach. We use a lightweight service layer only for operations that touch 2+ models or call external APIs. No query objects, no form objects, no presenters. The overhead isn’t justified until you hit roughly 50 models or 30+ controllers.

Fat models are fine until they’re not. We’ve seen teams extract service objects from a 100-line model “for clean architecture.” That’s premature. Our threshold: if a model file fits on two screens (~150 lines), leave it alone. Extract when you feel pain, not when a blog post tells you to.

Service objects vs. concerns: we use both. The Rails community sometimes treats these as opposing camps. In practice, concerns work well for shared behavior across models (e.g., Taggable, Auditable). Service objects work better for cross-model workflows (e.g., “process this order”). They solve different problems.

The dry-rb ecosystem is powerful but has a learning curve. We reach for dry-validation and dry-struct on larger projects (50+ models) where type safety and contract validation matter. For smaller apps, ActiveModel::Model with standard Rails validations does the job with zero extra dependencies.

Our real directory structure on a mid-size project (~80 models):

app/
├── controllers/
├── models/
├── services/           # ~40 service objects
├── queries/            # ~12 query objects
├── forms/              # ~8 form objects (multi-model forms only)
├── jobs/
├── mailers/
└── serializers/        # API responses

No presenters folder. We use view components (the view_component gem) instead, which combine presentation logic and templates in one place. No adapters folder either. External API wrappers live in lib/ with their own test suite.

When do we go full layered? When the app has:

  • 50+ models
  • 3+ developers working simultaneously
  • Complex domain logic that changes frequently (tax calculations, compliance rules, multi-step workflows)
  • Multiple interfaces (web, API, background processing) sharing business logic

Below those thresholds, Rails conventions plus a handful of service objects get you further than a formal architecture.

Form Objects: Useful for Multi-Model Forms, Skip for Simple CRUD

Form objects shine when a single form creates or updates records across multiple tables, or when validation rules differ from model validations.

class RegistrationForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :email, :string
  attribute :password, :string
  attribute :company_name, :string
  attribute :plan, :string

  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :password, presence: true, length: { minimum: 8 }
  validates :plan, inclusion: { in: %w[starter growth enterprise] }

  def save
    return false unless valid?

    ActiveRecord::Base.transaction do
      user = User.create!(email: email, password: password)
      Company.create!(name: company_name, owner: user)
      Subscription.create!(user: user, plan: plan)
    end

    true
  rescue ActiveRecord::RecordInvalid => e
    errors.add(:base, e.message)
    false
  end
end

Don’t create form objects for single-model forms. If User.new(user_params) with model validations works, that’s your form object. Adding a separate class for no reason adds indirection without value.

Communication Between Layers: Keep It Simple

Three patterns that work in practice, ordered from simplest to most complex:

1. Direct method calls (default for most apps)

Controller calls service, service calls model. No abstraction needed.

2. Events with ActiveSupport::Notifications (for decoupling side effects)

class ProcessOrder
  def call
    # ... core logic ...
    ActiveSupport::Notifications.instrument("order.completed", order: order)
  end
end

# In an initializer:
ActiveSupport::Notifications.subscribe("order.completed") do |*, payload|
  SendOrderConfirmationJob.perform_later(payload[:order].id)
  UpdateAnalyticsJob.perform_later(payload[:order].id)
end

Use events when you have 3+ side effects triggered by one action. Below that, direct calls are clearer.

3. Dependency injection (for testability)

class ProcessOrder
  def initialize(order_params, user, payment_gateway: StripeGateway.new)
    @payment_gateway = payment_gateway
  end
end

# In tests:
ProcessOrder.new(params, user, payment_gateway: FakeGateway.new)

Dependency injection is worth the effort for external services (payment, shipping, email). Don’t bother injecting internal classes like other service objects or models.

When NOT to Use Layered Architecture

Layered architecture has real costs. Every new abstraction is a file to maintain, a concept to explain to new hires, and an indirection that makes debugging harder.

Skip it when:

  • Your app has fewer than 15 controllers and 20 models
  • You’re building an MVP or prototype that might pivot
  • Your team is 1-2 developers who already know where everything is
  • The app is mostly CRUD with little domain logic

Signs you need it:

  • Models regularly exceed 300 lines
  • Controller actions exceed 30 lines
  • You’re writing the same query logic in multiple places
  • New developers take more than a week to make their first meaningful PR
  • Bug fixes in one feature break unrelated features

The best time to introduce layers is when the pain of not having them exceeds the cost of adding them. For most Rails apps, that happens somewhere between 30-50 models.

FAQs

At what project size should I introduce layered architecture?

There is no universal answer, but a useful heuristic: if your app has 30+ models, 3+ developers, and domain logic that changes frequently, layered architecture will save you time. For smaller apps, Rails conventions with a few service objects are enough.

What is the difference between the application layer and the domain layer?

The application layer orchestrates workflows. It knows which steps to run and in what order (validate input, charge payment, send email). The domain layer contains the rules themselves (how to calculate tax, what makes an order valid, when inventory is low). Service objects typically live in the application layer. Business rule objects live in the domain layer.

Should I use the interactor gem or plain Ruby service objects?

For workflows with 4+ steps and rollback requirements, the interactor gem provides useful structure (organize, rollback, context objects). For simpler 1-3 step operations, plain Ruby classes with a call method and a result struct are lighter and easier to understand. You can mix both in the same project.