Opening
By day four, you've deployed a few Rails apps and started asking yourself: why are my models, controllers and views organized the way they are, and does it actually matter? Rails directory structure: practical tips and trade-offs is the question that separates developers who follow convention from those who understand the reasons behind it. Understanding where everything lives—and why—saves you hours of debugging, onboarding friction, and architectural debt down the line.
The MVC triangle: app/models, app/controllers, and app/views
Rails enforces a clean separation of concerns by default. Your app/models directory holds your business logic and database layer. Your app/controllers directory orchestrates requests and responses. Your app/views directory renders the user-facing presentation. This isn't arbitrary—it's the backbone of Rails' convention over configuration philosophy.
In production systems we've worked on, the temptation to break this pattern appears around month three. You'll see controllers grow to 500 lines, models inflate to handle too much, and views coupled to specific controller logic. When you're building an e-commerce platform or a CRM, the pressure to ship fast can erode this structure quickly.
Let's look at a realistic example from a ride-hailing system:
# app/models/booking.rb
class Booking < ApplicationRecord
belongs_to :user
belongs_to :driver
has_many :notifications, dependent: :destroy
enum status: { pending: 0, accepted: 1, completed: 2, cancelled: 3 }
validates :pickup_location, :dropoff_location, presence: true
def eligible_drivers
Driver.where(available: true)
.near(pickup_location, 5)
.where.not(id: driver_id)
end
endYour model stays focused on data relationships and validation. Now the controller handles the HTTP request flow:
# app/controllers/bookings_controller.rb
class BookingsController < ApplicationController
def create
@booking = current_user.bookings.build(booking_params)
if @booking.save
BookingNotificationJob.perform_later(@booking.id)
render json: @booking, status: :created
else
render json: @booking.errors, status: :unprocessable_entity
end
end
private
def booking_params
params.require(:booking).permit(:pickup_location, :dropoff_location)
end
endThe controller doesn't calculate distance or query nearby drivers—that logic belongs in the model. The view or API response layer simply renders what the controller provides. This separation means you can test business logic without HTTP concerns and test HTTP flows without database hits.
config/routes.rb: the map of your application
config/routes.rb is your application's public contract. It defines every endpoint users and clients can reach. In large Rails apps, this file becomes the single source of truth for API surface area. When onboarding, developers read routes first to understand what the system does.
We've seen production codebases where routes.rb grew to 300+ lines because nested resources went three levels deep and exceptions accumulated over time. The trade-off: Rails routing is powerful enough to encode business logic, but the cost is readability.
Here's a clean approach for a SaaS platform:
# config/routes.rb
Rails.application.routes.draw do
devise_for :users
namespace :api, format: :json do
namespace :v1 do
resources :organizations, only: [:show, :update] do
resources :members, only: [:index, :create, :destroy]
resources :projects do
resources :tasks, shallow: true
end
end
resources :users, only: [:show] do
member do
patch :change_password
end
end
end
end
root to: 'pages#home'
endKey observations: shallow: true prevents nested routes from becoming unwieldy (your task show endpoint is /api/v1/tasks/:id, not /api/v1/organizations/:org_id/projects/:project_id/tasks/:id). Version your API namespace from day one. Use only: to restrict which RESTful actions are available—this forces you to think about your domain.
db/migrate: your schema history
The db/migrate directory isn't just for database changes—it's an auditable record of how your data model evolved. Each migration is timestamped and numbered, allowing your entire team to replay your schema history from blank slate to production state. This is critical for onboarding new developers and spinning up test databases.
We've inherited codebases where migrations were deleted or combined after the fact. Every subsequent developer spent cycles figuring out what the actual schema was supposed to be. Don't do that.
A well-structured migration tells a story:
# db/migrate/20240104123456_create_bookings.rb
class CreateBookings < ActiveRecord::Migration[7.0]
def change
create_table :bookings do |t|
t.references :user, null: false, foreign_key: true
t.references :driver, null: false, foreign_key: { to_table: :users }
t.string :pickup_location, null: false
t.string :dropoff_location, null: false
t.integer :status, default: 0
t.decimal :fare_cents, precision: 10, scale: 0
t.timestamps
end
add_index :bookings, [:user_id, :created_at]
add_index :bookings, :status
end
endNotice: null: false enforces data integrity at the database level. Foreign keys maintain referential integrity. Indexes on status and the user/created_at combination support the queries you'll actually run (filtering by status, or finding a user's recent bookings). Each migration is small and focused on a single change.
app/jobs and app/mailers: asynchronous work
In production systems handling real traffic, not everything can happen synchronously. Sending emails, processing images, syncing external APIs—these operations belong in background jobs and mailers. Rails provides app/jobs for Sidekiq/Delayed Job work and app/mailers for transactional and bulk email.
The directory structure signals intent: jobs are for async processing, mailers are for email generation and delivery. This keeps your controllers thin and your request/response cycle fast.
A practical example from a trading platform:
# app/jobs/booking_notification_job.rb
class BookingNotificationJob < ApplicationJob
queue_as :default
def perform(booking_id)
booking = Booking.find(booking_id)
BookingMailer.with(booking: booking)
.confirmation_email.deliver_later
booking.eligible_drivers.each do |driver|
DriverNotificationService.new(driver, booking).notify
end
end
endEnqueue this from your controller after the booking is saved (as shown earlier). The job runs outside the request cycle, so your user gets a fast response. If notification delivery fails, your job queue retries automatically.
# app/mailers/booking_mailer.rb
class BookingMailer < ApplicationMailer
default from: 'noreply@booking.dev'
def confirmation_email
@booking = params[:booking]
@user = @booking.user
mail(to: @user.email, subject: 'Booking confirmed')
end
endThe mailer generates email markup and handles delivery. In staging, you can intercept emails. In production, ActionMailer delegates to SendGrid or your mail service. Your mailer view lives in app/views/booking_mailer/confirmation_email.html.erb—same view layer pattern, applied to email.
Common mistakes
- Putting business logic in controllers. We've seen controllers that calculate discounts, apply fees, or sync with external APIs. This logic should live in models or service objects. Controllers orchestrate; they don't compute.
- Creating god models. When your User model has 500 lines and knows about billing, subscriptions, notifications, and integrations, you've lost the forest. Extract concerns into separate classes or service objects.
- Nesting routes more than two levels deep.
/organizations/:id/projects/:project_id/tasks/:task_idbecomes unmaintainable. Useshallow: trueor flat routes for the deeply nested resources. - Mixing jobs and synchronous work. If you're calling a third-party API in your controller or view, you're coupling request latency to external latency. Move it to a job.
- Ignoring database-level constraints. Rely on both ActiveRecord validations and database constraints. A missing
foreign_key: trueornull: falsewill haunt you during a data migration.
Key takeaways
- Keep models focused on data relationships and validation; keep controllers focused on request orchestration; keep views focused on presentation. This separation is enforced by directory structure for good reason.
config/routes.rbis your application's public API map. Version it, document it, and review it during code review. Use shallow nesting and namespace organization to keep it readable.- Migrations are permanent records. Write them carefully, index strategically, and add constraints at the database level. Never delete or modify old migrations after they've been run.
- Background jobs (
app/jobs) and mailers (app/mailers) are the bridge between synchronous web requests and asynchronous work. Use them to keep your request/response cycle fast and your application responsive. - The Rails directory structure isn't a constraint—it's a communication tool. Your team reads the structure to understand what the codebase does. Respect it, and you'll save cycles on onboarding, debugging, and maintenance.






Comments
Loading comments…