Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 004 / 100

Rails directory structure: practical tips and trade-offs

Where everything lives.

Expona Admin6 min read

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:

ruby
# 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
end

Your model stays focused on data relationships and validation. Now the controller handles the HTTP request flow:

ruby
# 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
end

The 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:

ruby
# 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'
end

Key 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:

ruby
# 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
end

Notice: 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:

ruby
# 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
end

Enqueue 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.

ruby
# 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
end

The 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_id becomes unmaintainable. Use shallow: true or 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: true or null: false will 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.rb is 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles