ruby-rails-patterns

Master Ruby on Rails MVC architecture, ActiveRecord patterns, and Rails conventions for building maintainable, scalable web applications.

coppermare/skillverse1 installsMITSynced Aug 22

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI
---
name: ruby-rails-patterns
description: Master Ruby on Rails MVC architecture, ActiveRecord patterns, and Rails conventions for building maintainable, scalable web applications.
license: MIT
---

# Ruby on Rails Patterns

Master Ruby on Rails conventions, MVC architecture, ActiveRecord patterns, and best practices for building maintainable, scalable web applications that embrace convention over configuration.

## When to Use This Skill

- Building web applications with Ruby on Rails
- Implementing MVC architecture patterns
- Working with ActiveRecord and database modeling
- Creating RESTful APIs with Rails
- Optimizing Rails application performance
- Writing maintainable Rails code
- Following Rails conventions and best practices

## Core Concepts

### 1. Rails MVC Architecture

**Model-View-Controller Flow**

```ruby
# Model - Business logic and data
class Article < ApplicationRecord
  belongs_to :author, class_name: 'User'
  has_many :comments, dependent: :destroy
  has_many :taggings, dependent: :destroy
  has_many :tags, through: :taggings

  validates :title, presence: true, length: { minimum: 5 }
  validates :body, presence: true
  validates :status, inclusion: { in: %w[draft published archived] }

  scope :published, -> { where(status: 'published') }
  scope :recent, -> { order(created_at: :desc) }
  scope :by_author, ->(author_id) { where(author_id: author_id) }

  def publish!
    update!(status: 'published', published_at: Time.current)
  end
end

# Controller - Request handling
class ArticlesController < ApplicationController
  before_action :authenticate_user!, except: [:index, :show]
  before_action :set_article, only: [:show, :edit, :update, :destroy]
  before_action :authorize_article!, only: [:edit, :update, :destroy]

  def index
    @articles = Article.published.recent.includes(:author, :tags)
    @articles = @articles.page(params[:page]).per(20)
  end

  def show
    @comments = @article.comments.includes(:user).order(created_at: :desc)
  end

  def create
    @article = current_user.articles.build(article_params)

    if @article.save
      redirect_to @article, notice: 'Article created successfully.'
    else
      render :new, status: :unprocessable_entity
    end
  end

  def update
    if @article.update(article_params)
      redirect_to @article, notice: 'Article updated successfully.'
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @article.destroy
    redirect_to articles_url, notice: 'Article deleted.'
  end

  private

  def set_article
    @article = Article.find(params[:id])
  end

  def article_params
    params.require(:article).permit(:title, :body, :status, tag_ids: [])
  end

  def authorize_article!
    redirect_to root_path, alert: 'Not authorized' unless @article.author == current_user
  end
end

# View - Presentation (app/views/articles/index.html.erb)
# <% @articles.each do |article| %>
#   <%= render article %>
# <% end %>
```

### 2. ActiveRecord Patterns

```ruby
# Associations
class User < ApplicationRecord
  has_many :articles, foreign_key: :author_id
  has_many :comments
  has_one :profile, dependent: :destroy
  has_and_belongs_to_many :roles

  # Polymorphic association
  has_many :notifications, as: :notifiable

  # Self-referential
  has_many :followings
  has_many :followers, through: :followings, source: :follower
end

# Validations
class Article < ApplicationRecord
  validates :title, presence: true,
                    length: { minimum: 5, maximum: 100 },
                    uniqueness: { scope: :author_id }

  validates :slug, format: { with: /\A[a-z0-9-]+\z/ }

  validates :status, inclusion: { in: %w[draft published] }

  validate :publish_date_cannot_be_in_past

  private

  def publish_date_cannot_be_in_past
    if published_at.present? && published_at < Date.today
      errors.add(:published_at, "can't be in the past")
    end
  end
end

# Callbacks
class Article < ApplicationRecord
  before_validation :generate_slug
  before_save :sanitize_content
  after_create :notify_followers
  after_commit :update_search_index, on: [:create, :update]

  private

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

  def sanitize_content
    self.body = ActionController::Base.helpers.sanitize(body)
  end

  def notify_followers
    NotifyFollowersJob.perform_later(author_id, id)
  end
end
```

### 3. Query Interface

```ruby
# Efficient querying
class ArticleQuery
  def initialize(relation = Article.all)
    @relation = relation
  end

  def published
    @relation = @relation.where(status: 'published')
    self
  end

  def by_author(author)
    @relation = @relation.where(author: author)
    self
  end

  def tagged_with(tag_name)
    @relation = @relation.joins(:tags).where(tags: { name: tag_name })
    self
  end

  def search(query)
    return self if query.blank?
    @relation = @relation.where('title ILIKE ? OR body ILIKE ?', "%#{query}%", "%#{query}%")
    self
  end

  def recent(limit = 10)
    @relation = @relation.order(created_at: :desc).limit(limit)
    self
  end

  def result
    @relation
  end
end

# Usage
articles = ArticleQuery.new
  .published
  .tagged_with('ruby')
  .search(params[:q])
  .recent(20)
  .result
```

## Essential Patterns

### Pattern 1: Service Objects

```ruby
# app/services/article_publisher.rb
class ArticlePublisher
  def initialize(article, notifier: ArticleNotifier.new)
    @article = article
    @notifier = notifier
  end

  def call
    return failure('Already published') if @article.published?
    return failure('Invalid article') unless @article.valid?

    ActiveRecord::Base.transaction do
      @article.update!(
        status: 'published',
        published_at: Time.current
      )

      create_activity_log
      @notifier.notify_followers(@article)
    end

    success(@article)
  rescue ActiveRecord::RecordInvalid => e
    failure(e.message)
  end

  private

  def create_activity_log
    Activity.create!(
      user: @article.author,
      action: 'published',
      trackable: @article
    )
  end

  def success(article)
    Result.new(success: true, article: article)
  end

  def failure(message)
    Result.new(success: false, error: message)
  end

  Result = Struct.new(:success, :article, :error, keyword_init: true) do
    def success?
      success
    end
  end
end

# Usage in controller
def publish
  result = ArticlePublisher.new(@article).call

  if result.success?
    redirect_to result.article, notice: 'Article published!'
  else
    redirect_to @article, alert: result.error
  end
end
```

### Pattern 2: Form Objects

```ruby
# app/forms/registration_form.rb
class RegistrationForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :email, :string
  attribute :password, :string
  attribute :password_confirmation, :string
  attribute :name, :string
  attribute :terms_accepted, :boolean

  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :password, presence: true, length: { minimum: 8 }
  validates :password_confirmation, presence: true
  validates :name, presence: true
  validates :terms_accepted, acceptance: true

  validate :passwords_match
  validate :email_uniqueness

  def save
    return false unless valid?

    ActiveRecord::Base.transaction do
      user = User.create!(
        email: email,
        password: password,
        name: name
      )

      Profile.create!(user: user)
      WelcomeMailer.welcome_email(user).deliver_later
    end

    true
  rescue ActiveRecord::RecordInvalid
    false
  end

  private

  def passwords_match
    return if password == password_confirmation
    errors.add(:password_confirmation, "doesn't match password")
  end

  def email_uniqueness
    return unless User.exists?(email: email)
    errors.add(:email, 'has already been taken')
  end
end

# Usage in controller
def create
  @form = RegistrationForm.new(registration_params)

  if @form.save
    redirect_to root_path, notice: 'Welcome!'
  else
    render :new, status: :unprocessable_entity
  end
end
```

### Pattern 3: Presenter/Decorator Pattern

```ruby
# app/presenters/article_presenter.rb
class ArticlePresenter < SimpleDelegator
  def initialize(article, view_context)
    @view = view_context
    super(article)
  end

  def formatted_date
    published_at&.strftime('%B %d, %Y') || 'Draft'
  end

  def reading_time
    words_per_minute = 200
    words = body.split.size
    minutes = (words / words_per_minute.to_f).ceil
    "#{minutes} min read"
  end

  def status_badge
    css_class = case status
                when 'published' then 'badge-success'
                when 'draft' then 'badge-warning'
                else 'badge-secondary'
                end

    @view.content_tag(:span, status.titleize, class: "badge #{css_class}")
  end

  def author_avatar
    if author.avatar.attached?
      @view.image_tag(author.avatar.variant(resize_to_fill: [40, 40]), class: 'avatar')
    else
      @view.image_tag('default_avatar.png', class: 'avatar')
    end
  end

  def truncated_body(length = 200)
    @view.truncate(@view.strip_tags(body), length: length)
  end

  def edit_link
    return unless @view.policy(self).edit?
    @view.link_to 'Edit', @view.edit_article_path(self), class: 'btn btn-sm btn-outline'
  end
end

# Helper method
module ArticlesHelper
  def present(article)
    ArticlePresenter.new(article, self)
  end
end

# Usage in view
# <%= present(@article).status_badge %>
# <%= present(@article).reading_time %>
```

### Pattern 4: Background Jobs

```ruby
# app/jobs/article_import_job.rb
class ArticleImportJob < ApplicationJob
  queue_as :default
  retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3
  discard_on ActiveJob::DeserializationError

  def perform(import_id)
    import = Import.find(import_id)
    import.update!(status: 'processing')

    importer = ArticleImporter.new(import.file)
    result = importer.process

    import.update!(
      status: 'completed',
      processed_count: result.processed,
      error_count: result.errors.count,
      errors_log: result.errors.to_json
    )

    ImportMailer.completed(import).deliver_later
  rescue StandardError => e
    import.update!(status: 'failed', errors_log: e.message)
    ImportMailer.failed(import, e.message).deliver_later
    raise
  end
end

# Sidekiq-specific configuration
class HeavyProcessingJob < ApplicationJob
  queue_as :heavy
  sidekiq_options retry: 5, backtrace: true

  def perform(data)
    # Heavy processing
  end
end
```

### Pattern 5: API Controllers

```ruby
# app/controllers/api/v1/articles_controller.rb
module Api
  module V1
    class ArticlesController < ApiController
      before_action :authenticate_api_user!
      before_action :set_article, only: [:show, :update, :destroy]

      def index
        @articles = Article.published
                          .includes(:author, :tags)
                          .order(created_at: :desc)
                          .page(params[:page])
                          .per(params[:per_page] || 20)

        render json: {
          articles: ArticleSerializer.new(@articles).serializable_hash,
          meta: pagination_meta(@articles)
        }
      end

      def show
        render json: ArticleSerializer.new(@article, include: [:author, :comments]).serializable_hash
      end

      def create
        @article = current_user.articles.build(article_params)

        if @article.save
          render json: ArticleSerializer.new(@article).serializable_hash, status: :created
        else
          render json: { errors: @article.errors }, status: :unprocessable_entity
        end
      end

      def update
        authorize @article

        if @article.update(article_params)
          render json: ArticleSerializer.new(@article).serializable_hash
        else
          render json: { errors: @article.errors }, status: :unprocessable_entity
        end
      end

      def destroy
        authorize @article
        @article.destroy
        head :no_content
      end

      private

      def set_article
        @article = Article.find(params[:id])
      end

      def article_params
        params.require(:article).permit(:title, :body, :status, tag_ids: [])
      end

      def pagination_meta(collection)
        {
          current_page: collection.current_page,
          total_pages: collection.total_pages,
          total_count: collection.total_count,
          per_page: collection.limit_value
        }
      end
    end
  end
end

# Base API controller
module Api
  class ApiController < ActionController::API
    include ActionController::HttpAuthentication::Token::ControllerMethods

    rescue_from ActiveRecord::RecordNotFound, with: :not_found
    rescue_from ActionController::ParameterMissing, with: :bad_request

    private

    def authenticate_api_user!
      authenticate_or_request_with_http_token do |token, _options|
        @current_user = User.find_by(api_token: token)
      end
    end

    def current_user
      @current_user
    end

    def not_found
      render json: { error: 'Not found' }, status: :not_found
    end

    def bad_request(exception)
      render json: { error: exception.message }, status: :bad_request
    end
  end
end
```

### Pattern 6: Concerns and Modules

```ruby
# app/models/concerns/sluggable.rb
module Sluggable
  extend ActiveSupport::Concern

  included do
    before_validation :generate_slug, if: :should_generate_slug?
    validates :slug, presence: true, uniqueness: true
  end

  class_methods do
    def find_by_slug!(slug)
      find_by!(slug: slug)
    end
  end

  def to_param
    slug
  end

  private

  def generate_slug
    base_slug = slug_source.parameterize
    self.slug = unique_slug(base_slug)
  end

  def slug_source
    respond_to?(:title) ? title : name
  end

  def should_generate_slug?
    slug.blank? && slug_source.present?
  end

  def unique_slug(base)
    slug = base
    counter = 1

    while self.class.where(slug: slug).where.not(id: id).exists?
      slug = "#{base}-#{counter}"
      counter += 1
    end

    slug
  end
end

# Usage
class Article < ApplicationRecord
  include Sluggable
end

# app/controllers/concerns/pagination.rb
module Pagination
  extend ActiveSupport::Concern

  def paginate(collection)
    collection.page(page).per(per_page)
  end

  def page
    params[:page]&.to_i || 1
  end

  def per_page
    [params[:per_page]&.to_i || 20, 100].min
  end

  def pagination_headers(collection)
    response.headers['X-Page'] = collection.current_page.to_s
    response.headers['X-Per-Page'] = collection.limit_value.to_s
    response.headers['X-Total'] = collection.total_count.to_s
    response.headers['X-Total-Pages'] = collection.total_pages.to_s
  end
end
```

## Best Practices

### 1. Fat Models, Skinny Controllers

```ruby
# Move logic to models or service objects
class Article < ApplicationRecord
  def can_be_published?
    valid? && draft? && author.can_publish?
  end
end
```

### 2. Use Strong Parameters

```ruby
def article_params
  params.require(:article).permit(:title, :body, tag_ids: [])
end
```

### 3. Eager Loading

```ruby
# Avoid N+1 queries
Article.includes(:author, :tags, comments: :user).all
```

### 4. Database Indexes

```ruby
# migration
add_index :articles, :author_id
add_index :articles, [:status, :created_at]
add_index :articles, :slug, unique: true
```

## Common Pitfalls

- **N+1 Queries**: Use includes/preload/eager_load
- **Fat Controllers**: Extract to services/form objects
- **Skipping Validations**: Use bang methods or check return values
- **Ignoring Background Jobs**: Move slow operations to jobs
- **Not Using Transactions**: Group related database changes
- **Hardcoding Configuration**: Use Rails credentials or ENV

## Resources

- Ruby on Rails Guides
- The Rails Way by Obie Fernandez
- Agile Web Development with Rails
- Rails API Documentation

More Backend Frameworks skills

← All Backend Frameworks skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY