How to Understand a Rails App: Routes, Callbacks and Concerns

Rails runs on convention, which is great until you need to find where something happens. Here is the reading order and the commands that make a Rails app legible.

By The OpenVisio Team · · 11 min read

Rails apps are easy to start and occasionally hard to inherit. Convention over configuration means a new developer knows where controllers and models live on day one. It also means a lot of behavior is implied: a model gains methods from concerns you did not open, a callback sends an email when a record is saved, a before_action in a parent controller redirects half the app, and Zeitwerk loads constants by file name, so there is no import line telling you where Billing::Invoice came from.

This guide is a reading order for an unfamiliar Rails application. It starts with the files that describe the whole app, moves through the request path, and then covers the places Rails runs code implicitly: callbacks, concerns, jobs and initializers. Along the way it uses the bin/rails commands that answer structural questions directly, because in Rails the running app is often a better source of truth than grepping.

Key takeaways

  • Read Gemfile, config/application.rb and bin/rails about first to learn the Rails version, major gems and load configuration.
  • bin/rails routes is the complete list of endpoints. Filter it with -c (controller) or -g (grep) instead of reading config/routes.rb alone.
  • Start every controller investigation at ApplicationController. Its before_action filters apply to everything below it.
  • db/schema.rb (or structure.sql) is the truest description of the data model. Read it alongside the models.
  • Callbacks and concerns hold much of a Rails app's behavior. List them explicitly instead of discovering them by surprise.
  • In the console, method(:name).source_location tells you exactly where any method is defined, including ones mixed in from concerns or gems.

Step 1: Get the shape of the app

Start at the root. Gemfile and Gemfile.lock tell you the Rails version and the important dependencies: Devise or a custom auth layer, Pundit or CanCanCan for authorization, Sidekiq or Solid Queue for jobs, Hotwire (Turbo and Stimulus) or a separate JavaScript front end, and API serializers like Jbuilder, ActiveModel::Serializers or Alba.

Then run:

bin/rails about

It prints the Rails, Ruby and Rack versions, the environment, the database adapter and the middleware stack summary. Read config/application.rb for load path changes (config.autoload_paths, config.eager_load_paths), time zone, default generators and config.api_only = true, which tells you there are no views to speak of.

Look at the top-level folders under app/. Beyond the standard controllers, models, views, jobs, mailers and helpers, many apps add services, queries, forms, policies, serializers, components (ViewComponent) or interactors. Every folder directly under app/ is autoloaded, so these are first-class parts of the architecture even though Rails does not know their purpose.

Step 2: Read the routes from the running app

config/routes.rb uses a DSL that compresses a lot into a little. resources :projects alone generates seven routes. Reading the file tells you intent; listing routes tells you reality:

bin/rails routes                     # everything
bin/rails routes -c projects         # routes for ProjectsController
bin/rails routes -g webhook          # grep paths, verbs and names
bin/rails routes --expanded          # one block per route, easier to read

While reading routes.rb, note:

  • namespace :admin and scope module: blocks, which map to Admin:: controllers under app/controllers/admin/.
  • constraints, which can route by subdomain or a custom lambda.
  • mount lines for engines: mount Sidekiq::Web => '/sidekiq', mount ActionCable.server => '/cable', or an internal engine. Mounted engines have their own routes that the main list shows only as the mount point.
  • concern :commentable route concerns, unrelated to model concerns despite the name.
  • draw(:admin) calls that split routes across files in config/routes/.

Step 3: Start controllers at ApplicationController

Every controller inherits from ApplicationController, which in turn inherits from ActionController::Base (or ActionController::API). Read it first. It typically contains authentication, tenant selection, locale handling, error rescue and authorization hooks:

class ApplicationController < ActionController::Base
  before_action :authenticate_user!
  before_action :set_current_account
  after_action :verify_authorized, except: :index

  rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
  rescue_from Pundit::NotAuthorizedError, with: :render_forbidden
end

Then read intermediate base classes like Admin::BaseController or Api::V1::BaseController. When you open a specific controller, list the filters in effect for an action by reading up the inheritance chain, and watch for skip_before_action, which removes protections for specific actions.

Inside actions, the conventions are strong: index, show, new, create, edit, update, destroy, with params.require(:project).permit(...) for strong parameters, usually in a private project_params method. Views are found by convention too: ProjectsController#show renders app/views/projects/show.html.erb unless told otherwise. If the action seems empty, it is rendering that template implicitly.

The middleware stack runs before any controller. List it with:

bin/rails middleware

Custom entries there, often added in config/application.rb or initializers, affect every request.

Step 4: Read the schema, then the models

db/schema.rb is generated from the database and lists every table, column, index and foreign key in one place. It is a more reliable map of the data than the models, because models only describe what the code chooses to declare. If the app uses config.active_record.schema_format = :sql, read db/structure.sql instead.

Then read models in app/models/. For each one, note:

  • Associations: belongs_to, has_many, has_many :through, has_one, and options like dependent: :destroy, which cascade deletes through callbacks.
  • Validations, including custom validate :method_name calls.
  • Scopes and default_scope. A default_scope silently filters every query on that model, and is a classic source of confusion.
  • Enums, which generate predicate and scope methods (enum :status, { draft: 0, published: 1 } gives you published? and Post.published).
  • include lines for concerns.
  • Delegations, has_secure_password, has_one_attached and other macros that generate methods.

The console is the fastest way to check what a model really has:

bin/rails console
Project.reflect_on_all_associations.map { |a| [a.macro, a.name] }
Project.validators.map { |v| [v.class.name.demodulize, v.attributes] }
Project.instance_method(:archive!).source_location
Project.ancestors.take(15)

source_location is the single most useful trick in an unfamiliar Rails app. It returns the file and line where a method is defined, whether that is the model, a concern, or a gem.

Step 5: List callbacks before they surprise you

Active Record callbacks run code on lifecycle events: before_validation, before_save, after_create, after_commit, after_destroy and others. They are where a lot of implicit behavior lives, such as setting slugs, syncing to a search index, broadcasting Turbo Streams, enqueuing jobs and sending emails.

Find them across the codebase:

grep -rnE "^\s*(before|after|around)_(validation|save|create|update|destroy|commit|rollback)" app/models

Or ask a model directly in the console:

Project._save_callbacks.map { |c| [c.kind, c.filter] }
Project._commit_callbacks.map { |c| [c.kind, c.filter] }

Two details matter when reading callbacks. Work that talks to the outside world (jobs, emails, webhooks) belongs in after_commit, because after_save runs inside the transaction and the job may run before the data is visible. And methods like update_column, update_all, insert_all and delete_all skip callbacks entirely, so data written that way bypasses the behavior you just found.

Step 6: Open the concerns

Concerns live in app/models/concerns/ and app/controllers/concerns/ and use ActiveSupport::Concern:

module Archivable
  extend ActiveSupport::Concern

  included do
    scope :active, -> { where(archived_at: nil) }
    before_destroy :prevent_if_archived
  end

  def archive!
    update!(archived_at: Time.current)
  end
end

The included do block runs in the context of the including class, so a concern can add scopes, associations, validations and callbacks, not just methods. When a model includes six concerns, its real definition is spread across seven files. For important models, open every concern they include before deciding you understand them.

Step 7: Find jobs, mailers and initializers

Jobs. app/jobs/ holds Active Job classes inheriting from ApplicationJob with a perform method. Find where they are enqueued:

grep -rn "perform_later\|perform_async\|deliver_later" app lib

Check config.active_job.queue_adapter to learn the backend. Rails 8 defaults to Solid Queue, configured in config/queue.yml with recurring tasks in config/recurring.yml. Sidekiq apps may use sidekiq-cron or sidekiq-scheduler with their own schedule files, and older apps may use the whenever gem's config/schedule.rb. Some Sidekiq apps skip Active Job and define workers that include Sidekiq::Job directly.

Mailers. app/mailers/ and their templates in app/views/<mailer_name>/ explain every email the app sends. Previews in test/mailers/previews or spec/mailers/previews render at /rails/mailers in development.

Initializers. Everything in config/initializers/ runs at boot, in alphabetical order. This is where API clients are configured, feature flags are loaded, monkey patches are applied and gems are customized. Skim every file; it is rarely long and often explains behavior that looks impossible from the app code.

Rake tasks. lib/tasks/*.rake holds operational tasks. List them with bin/rails -T.

Step 8: Confirm autoloading and trace a request

Rails uses Zeitwerk, which maps file paths to constants: app/services/billing/charge_card.rb must define Billing::ChargeCard. That makes "where is this class defined" a path question. If you suspect a mismatch, run:

bin/rails zeitwerk:check

Finally, trace one request with the log open. tail -f log/development.log shows the controller and action, parameters, every SQL query, rendered templates and enqueued jobs for each request. Click through one important flow and read the log line by line. The general method is in tracing a request through a web app.

A first-day checklist

  1. Read Gemfile, config/application.rb and the output of bin/rails about.
  2. List routes and note namespaces and mounted engines.
  3. Read ApplicationController and every base controller.
  4. Read db/schema.rb, then the most important models.
  5. List callbacks and open every concern on core models.
  6. Find job enqueue sites and the queue adapter and schedules.
  7. Skim every initializer.
  8. Trace one write request in the development log.

If you are working alongside an AI coding agent on a Rails app, the implicit loading makes structural context especially valuable; how AI coding agents understand a codebase covers why. Codebase Knowledge by OpenVisio can index a Rails repo in the browser and let you ask questions like "what happens after an order is created" in Ask, which is a useful cross-check on the callback list you built by hand.

Ruby on RailsRubyweb frameworkscode reading

See it on your own repo

Paste a GitHub URL and get the day-one walkthrough — free, in your browser.

Index a repo

Keep reading