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
| Layer | Responsibility | Rails mapping | Introduce when… |
|---|---|---|---|
| Presentation | Render output, handle user input | Views, serializers, React/Vue | Always present |
| Application | Orchestrate workflows | Controllers, service objects | Controllers exceed ~30 lines |
| Domain | Business rules, invariants | Plain Ruby objects, ActiveModel | Model files exceed ~200 lines |
| Infrastructure | Persistence, APIs, jobs | ActiveRecord, Sidekiq, adapters | Always 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

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_savethat 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
Ordermodel 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:
- Plain Ruby with a
callmethod returning a result struct interactorgem (ordry-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.