The MVC Pattern in Rails

· 7 min read · Updated March 28, 2026 · beginner
mvc rails ruby web

Every Rails application follows the same underlying structure. Understanding that structure is the difference between writing code that works and understanding why it works. The pattern Rails uses is called MVC — Model, View, Controller — and it separates your application into three distinct layers, each with a single responsibility.

This tutorial walks through what MVC means in Rails, how a request travels through each layer, and how the pieces fit together with real code examples.

What MVC Actually Means in Rails

MVC is an architectural pattern. In Rails, each layer has a specific job:

  • Model — manages your data and the business rules around it. In Rails, this is powered by ActiveRecord, which maps your Ruby objects to database tables.
  • View — renders the response. It takes data from the controller and turns it into HTML, JSON, or whatever format the user needs.
  • Controller — coordinates the flow. It receives the incoming request, talks to the model, and hands data off to the view.

The key benefit is that each piece doesn’t need to know how the others work. Change your HTML without touching your database queries. Rework your business logic without rewriting your controller. Developers can work on different layers without stepping on each other’s toes.

The Request Lifecycle

Before diving into each layer, it helps to see the full picture. When a browser makes a request to a Rails application, here’s what happens:

BrowserRouterControllerModelControllerViewResponse

Let’s trace through a concrete example: GET /posts

  1. The Router receives the request and matches GET /posts to PostsController#index.
  2. The Controller action index runs. It calls Post.all on the model.
  3. The Model queries the database and returns a collection of post objects.
  4. The Controller stores that collection in an instance variable: @posts = Post.all.
  5. The View renders app/views/posts/index.html.erb, where @posts is available.
  6. The finished HTML is sent back to the browser.

That full round-trip is the request lifecycle. Every request in Rails follows this path.

The Model Layer

The model is backed by ActiveRecord, which is an ORM — an Object-Relational Mapping layer. Instead of writing SQL, you work with Ruby objects, and ActiveRecord handles the translation to your database.

Models define the structure of your data and the relationships between pieces of it.

class Post < ActiveRecord::Base
  has_many :comments, dependent: :destroy
  belongs_to :author, class_name: "User"

  validates :title, presence: true, length: { minimum: 5 }
  validates :slug, uniqueness: true

  before_validation :generate_slug

  private

  def generate_slug
    self.slug ||= title.parameterize
  end
end

has_many and belongs_to are association declarations. They give you methods like post.comments, comment.post, and post.comments.create(...) without writing a line of SQL.

Validations run before the model saves to the database. If validation fails, save returns false and the record doesn’t persist. This keeps bad data out of your system at the application level.

Callbacks like before_validation let you hook into the object’s lifecycle. You can run logic before saving, after creating, around destroying — whatever you need.

Migrations handle your schema. They’re version-controlled Ruby files that describe changes to your tables:

class CreatePosts < ActiveRecord::Migration[7.0]
  def change
    create_table :posts do |t|
      t.string :title
      t.text :body
      t.timestamps
    end
  end
end

Run rails db:migrate and Rails creates the posts table with the columns you defined.

The Controller Layer

Controllers receive the HTTP request and coordinate everything else. A RESTful Rails controller typically exposes seven actions that correspond to the standard CRUD operations.

class PostsController < ApplicationController
  before_action :set_post, only: [:show, :edit, :update, :destroy]

  def index
    @posts = Post.all
  end

  def show
    # @post set by before_action
  end

  def new
    @post = Post.new
  end

  def create
    @post = Post.new(post_params)
    if @post.save
      redirect_to @post, notice: "Post was successfully created."
    else
      render :new, status: :unprocessable_entity
    end
  end

  def edit
    # @post set by before_action
  end

  def update
    if @post.update(post_params)
      redirect_to @post, notice: "Post was successfully updated."
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @post.destroy
    redirect_to posts_url, notice: "Post was successfully deleted."
  end

  private

  def set_post
    @post = Post.find(params[:id])
  end

  def post_params
    params.require(:post).permit(:title, :body, :slug)
  end
end

before_action :set_post runs the set_post method before specific actions. It keeps repetitive code out of your actions. find(params[:id]) looks up the record by its primary key — the :id comes from the URL route like /posts/42.

The post_params method uses strong parameters to whitelist which fields can be submitted. require(:post) ensures the :post key is present in the request. permit(...) lists the allowed fields. Anything else is silently ignored. This protects against mass assignment vulnerabilities.

A common mistake is using redirect_to :new after a failed create. That tells the browser to make a new GET request, which loses all the form data the user submitted. The correct approach is render :new, which re-renders the form while keeping the user’s input intact.

The View Layer

Views live in app/views/ and use ERB templates — HTML files with embedded Ruby. The view’s only job is to present data. It should never query the database or modify application state.

<%# app/views/posts/index.html.erb %>
<h1>All Posts</h1>

<%= link_to "New Post", new_post_path %>

<% @posts.each do |post| %>
  <article>
    <h2><%= link_to post.title, post %></h2>
    <p><%= truncate(post.body, length: 100) %></p>
  </article>
<% end %>

The two ERB syntaxes behave differently. <%= %> evaluates the expression and outputs the result into the HTML. <% %> evaluates without outputting — useful for control flow like each loops. Using <%= %> inside an <% %> block to output values within a loop is fine.

link_to, truncate, and pluralize are view helpers. Rails ships with a large set of helpers that handle common presentation tasks. You can also write your own.

Instance variables set in the controller are automatically available in the view. If your controller sets @posts, the view can access @posts directly. That’s the primary communication channel between the two layers.

What views should not do: query the database (Post.find(...)), modify params, or call redirect_to. Those responsibilities belong to the controller.

Layouts and Partials

Every page on a Rails site shares a common wrapper. That wrapper lives in a layout file:

app/views/layouts/application.html.erb

Partials are reusable template fragments. A _form.html.erb partial for a post might look like:

<%# app/views/posts/_form.html.erb %>
<%= form_with model: post do |f| %>
  <% if post.errors.any? %>
    <div class="errors">
      <ul>
        <% post.errors.full_messages.each do |msg| %>
          <li><%= msg %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div>
    <%= f.label :title %>
    <%= f.text_field :title %>
  </div>
  <div>
    <%= f.label :body %>
    <%= f.text_area :body %>
  </div>
  <%= f.submit %>
<% end %>

Render it from any view with:

<%= render 'form', post: @post %>

The error check uses post.errors.any? to determine whether to display anything. If the model has validation errors, full_messages returns an array like ["Title is too short (minimum is 5 characters)", "Body can't be blank"]. This pattern works with any form built using form_with, form_for, or form_tag. Note that f.error_message_on is not a standard FormBuilder method — it doesn’t exist in Rails and would raise a NoMethodError.

Conventions Over Configuration

Rails reduces boilerplate by assuming conventions. A PostsController automatically looks for the Post model, the posts table in the database, and views in app/views/posts/. You don’t have to configure any of that explicitly.

ConventionExample
Controller namePostsController/posts
Model namePost (singular, CamelCase)
Table nameposts (plural, snake_case)
Primary keyid
Foreign keypost_id
View folderapp/views/posts/

When you follow the conventions, Rails connects all the pieces automatically. When you need to deviate, you can override them — but most of the time, the conventions handle everything.

See Also