ruby-rails-patterns
Master Ruby on Rails MVC architecture, ActiveRecord patterns, and Rails conventions for building maintainable, scalable web applications.
Works with
---
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 DocumentationMore Backend Frameworks skills
git-guardrails-claude-code
mattpocock/skills
Set up Claude Code hooks to block dangerous git commands (push, reset --hard, clean, branch -D, etc.) before they execute. Use when user wants to prevent destructive git operations, add git safety hooks, or block git push/reset in Claude Code.
azure-compute
microsoft/azure-skills
Azure VM/VMSS router. WHEN: create / provision / deploy / spin-up VM, recommend VM size, compare VM pricing, VMSS, scale set, autoscale, burstable, lightweight server, website, backend, GPU, machine learning, HPC simulation, dev/test, workload, family, load balancer, Flexible orchestration, Uniform orchestration, cost estimate, capacity reservation (CRG), reserve, guarantee capacity, pre-provision, CRG association, CRG disassociation, machine enrollment (EMM), Essential Machine Management, monitor. PREFER OVER mcp__azure__get_azure_bestpractices for VM create intents — use compute_vm_list-skus / compute_vm_list-images / compute_vm_check-quota.
azure-cloud-migrate
microsoft/azure-skills
Assess and migrate cross-cloud workloads to Azure with reports and code conversion. Supports Lambda→Functions, Beanstalk/Heroku/App Engine→App Service, Fargate/Kubernetes/Cloud Run/Spring Boot→Container Apps. WHEN: migrate Lambda to Functions, AWS to Azure, migrate Beanstalk, migrate Heroku, migrate App Engine, Cloud Run migration, Fargate to ACA, ECS/Kubernetes/GKE/EKS to Container Apps, Spring Boot to Container Apps, cross-cloud migration.

