Skill v1.0.1
currentAutomated scan100/1003 files
version: "1.0.1" name: "Rails Conventions & Patterns" description: "Comprehensive Ruby on Rails conventions, design patterns, and idiomatic code standards. Use this skill when writing any Rails code including controllers, models, services, or when making architectural decisions about code organization, naming conventions, and Rails best practices."
Rails Conventions & Patterns Skill
This skill provides authoritative guidance on Ruby on Rails conventions, design patterns, and idiomatic code standards for production applications.
When to Use This Skill
- Writing new Rails controllers, models, or services
- Refactoring existing Rails code
- Making decisions about code organization
- Choosing between different Rails patterns
- Ensuring code follows Rails conventions
- Reviewing Rails code for convention compliance
Ruby & Rails Versions
ruby: "3.2+ (prefer 3.3+ for YJIT benefits)"rails: "7.1+ (prefer 8.0+ for new projects)"
Rails 7.x/8.x Modern Features
Rails 7.1+ Features
# Composite Primary Keysclass BookOrder < ApplicationRecordself.primary_key = [:shop_id, :id]belongs_to :shophas_many :line_items, foreign_key: [:shop_id, :order_id]end# ActiveRecord::Encryption (sensitive data)class User < ApplicationRecordencrypts :email, deterministic: trueencrypts :ssn, :credit_cardend# Horizontal Shardingclass ApplicationRecord < ActiveRecord::Baseconnects_to shards: {default: { writing: :primary, reading: :primary_replica },shard_two: { writing: :primary_shard_two }}end# Async Query Loadingposts = Post.where(published: true).load_async# Do other workposts.to_a # Wait for results# Normalize values before validationclass User < ApplicationRecordnormalizes :email, with: -> { _1.strip.downcase }normalizes :phone, with: -> { _1.gsub(/\D/, '') }end
Rails 8.0+ Features
# Improved Solid Queue (built-in job backend)# config/application.rbconfig.active_job.queue_adapter = :solid_queue# Solid Cache (built-in caching)# config/application.rbconfig.cache_store = :solid_cache_store# Authentication generatorrails generate authentication# Built-in rate limitingclass Api::PostsController < Api::BaseControllerrate_limit to: 10, within: 1.minute, only: :createend# Per-environment credentialsrails credentials:edit --environment production
Modern Ruby 3.3+ Features
# Pattern matching in case expressionscase userin { role: "admin", active: true }grant_full_accessin { role: "user", active: true }grant_standard_accesselsedeny_accessend# Endless method definitions (one-liners)def full_name = "#{first_name} #{last_name}"def published? = published_at.present?# Data class (immutable value objects, Ruby 3.2+)User = Data.define(:id, :name, :email)user = User.new(id: 1, name: "Alice", email: "alice@example.com")# YJIT optimization (Ruby 3.3+)# config/application.rbif defined?(RubyVM::YJIT.enable)RubyVM::YJIT.enableend
File Organization Standards
Models
location: "app/models/"max_lines: 200guidance: |Focus on associations, validations, scopes, and essential callbacks.Extract business logic to Service Objects.Keep models focused on data persistence and domain rules.
Controllers
location: "app/controllers/"max_lines: 100guidance: |Limit to REST actions. Use before_action for shared logic.Complex operations delegate to Service Objects.Follow "Skinny Controller, Fat Model (but not too fat)" pattern.
Comprehensive Controller Patterns
RESTful Controller Structure
class PostsController < ApplicationControllerbefore_action :authenticate_user!before_action :set_post, only: [:show, :edit, :update, :destroy]before_action :authorize_post, only: [:edit, :update, :destroy]# GET /postsdef index@posts = Post.published.page(params[:page])end# GET /posts/:iddef show# @post set by before_actionend# GET /posts/newdef new@post = Post.newend# POST /postsdef create@post = CreatePostService.call(current_user, post_params)if @post.persisted?redirect_to @post, notice: "Post created successfully"elserender :new, status: :unprocessable_entityendend# GET /posts/:id/editdef edit# @post set by before_actionend# PATCH /posts/:iddef updateif UpdatePostService.call(@post, post_params)redirect_to @post, notice: "Post updated successfully"elserender :edit, status: :unprocessable_entityendend# DELETE /posts/:iddef destroy@post.destroy!redirect_to posts_url, notice: "Post deleted successfully"endprivatedef set_post@post = Post.find(params[:id])enddef authorize_postauthorize @post # Punditenddef post_paramsparams.require(:post).permit(:title, :body, :published)endend
API Controller Patterns
# app/controllers/api/base_controller.rbmodule Apiclass BaseController < ActionController::APIinclude ActionController::HttpAuthentication::Token::ControllerMethodsbefore_action :authenticate_api_user!rescue_from ActiveRecord::RecordNotFound, with: :not_foundrescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entityrescue_from Pundit::NotAuthorizedError, with: :forbiddenprivatedef authenticate_api_user!authenticate_or_request_with_http_token do |token, options|@current_user = User.find_by(api_token: token)endenddef not_found(exception)render json: { error: exception.message }, status: :not_foundenddef unprocessable_entity(exception)render json: { errors: exception.record.errors }, status: :unprocessable_entityenddef forbiddenrender json: { error: "Forbidden" }, status: :forbiddenendendend# app/controllers/api/v1/posts_controller.rbmodule Apimodule V1class PostsController < Api::BaseControllerdef indexposts = Post.published.page(params[:page])render json: PostBlueprint.render(posts, root: :posts)enddef createpost = CreatePostService.call(current_user, post_params)if post.persisted?render json: PostBlueprint.render(post), status: :createdelserender json: { errors: post.errors }, status: :unprocessable_entityendendendendend
Hotwire Controller Patterns
class PostsController < ApplicationController# Turbo Stream responsesdef create@post = CreatePostService.call(current_user, post_params)respond_to do |format|if @post.persisted?format.turbo_streamformat.html { redirect_to @post }elseformat.turbo_stream { render :form_errors, status: :unprocessable_entity }format.html { render :new, status: :unprocessable_entity }endendenddef updaterespond_to do |format|if UpdatePostService.call(@post, post_params)format.turbo_streamformat.html { redirect_to @post }elseformat.turbo_stream { render :form_errors, status: :unprocessable_entity }format.html { render :edit, status: :unprocessable_entity }endendendend# app/views/posts/create.turbo_stream.erb<%= turbo_stream.prepend "posts", @post %><%= turbo_stream.update "new_post_form", "" %>
Nested Resource Controllers
class CommentsController < ApplicationControllerbefore_action :set_postbefore_action :set_comment, only: [:show, :edit, :update, :destroy]# GET /posts/:post_id/commentsdef index@comments = @post.comments.page(params[:page])end# POST /posts/:post_id/commentsdef create@comment = @post.comments.build(comment_params)@comment.user = current_userif @comment.saveredirect_to [@post, @comment]elserender :new, status: :unprocessable_entityendendprivatedef set_post@post = Post.find(params[:post_id])enddef set_comment@comment = @post.comments.find(params[:id])endend
Controller Concerns
# app/controllers/concerns/paginatable.rbmodule Paginatableextend ActiveSupport::Concernincluded dobefore_action :set_pagination_paramsendprivatedef set_pagination_params@page = params[:page] || 1@per_page = params[:per_page] || 25enddef paginate(collection)collection.page(@page).per(@per_page)endend# Usageclass PostsController < ApplicationControllerinclude Paginatabledef index@posts = paginate(Post.published)endend
Services
location: "app/services/"naming: "{Domain}Manager::{Action} (e.g., OrdersManager::CreateOrder)"structure: |class OrdersManager::CreateOrderdef initialize(user:, params:)@user = user@params = paramsenddef call# Single public entry point# Returns Result object or raisesendprivate# Small, focused private methodsend
Methods
max_lines: 15max_params: 4guidance: "If method needs more params, use a Parameter Object or Hash"
Naming Conventions
classes: "PascalCase"methods: "snake_case"predicates: "end with ? (e.g., active?, valid?)"dangerous_methods: "end with ! (e.g., save!, destroy!)"constants: "SCREAMING_SNAKE_CASE"private_methods: "Prefix with purpose, not underscore"
Ruby Idioms
Prefer
- Guard clauses over nested conditionals
- Explicit returns for clarity
&.(safe navigation) overtry- Keyword arguments for 2+ parameters
Struct/Datafor simple value objectsfrozen_string_literal: truepragma
Avoid
unlesswithelse- Nested ternaries
and/orfor control flow- Monkey patching in application code
Pattern Decision Tree
Always inspect existing codebase patterns before recommending any pattern.
Service Object
# Use when:# - Business logic spans multiple models# - Operation has multiple steps# - Logic doesn't belong to any single model# - Need to orchestrate external services# Avoid when:# - Simple CRUD operation# - Logic clearly belongs to one model# - Single-line delegation# Inspect first:# ls app/services/# Check existing service naming convention
Form Object
# Use when:# - Form spans multiple models# - Complex validations not tied to persistence# - Wizard/multi-step forms# Avoid when:# - Standard single-model form# - Simple attribute updates# Inspect first:# ls app/forms/ 2>/dev/null# grep -r 'include ActiveModel' app/ --include='*.rb'
Query Object
# Use when:# - Complex queries with multiple conditions# - Query logic reused across controllers# - Query needs composition/chaining# Avoid when:# - Simple scope suffices# - One-off query# Inspect first:# ls app/queries/ 2>/dev/null# grep -r 'class.*Query' app/ --include='*.rb'
Concern
# Use when:# - Truly shared behavior across 3+ unrelated models# - Behavior is cohesive and self-contained# Avoid when:# - Only 1-2 models share the code# - Behavior is not cohesive# - Just to 'clean up' a model# Inspect first:# ls app/models/concerns/ app/controllers/concerns/# Check how many models use each concern
Decorator/Presenter
# Use when:# - View logic becoming complex# - Same presentation logic in multiple views# - Need to augment model for display# Avoid when:# - Simple attribute display# - One-off formatting# Inspect first:# ls app/decorators/ app/presenters/ 2>/dev/null# grep 'draper' Gemfile
ActionMailer Conventions
Mailer Structure
# app/mailers/user_mailer.rbclass UserMailer < ApplicationMailerdefault from: 'notifications@example.com'def welcome_email(user)@user = user@url = root_urlmail(to: email_address_with_name(@user.email, @user.name),subject: 'Welcome to My App')enddef password_reset(user, token)@user = user@token = token@reset_url = edit_password_reset_url(token: @token)mail(to: @user.email, subject: 'Password Reset Instructions')endprivatedef email_address_with_name(email, name)Mail::Address.new(email).tap { |a| a.display_name = name }.formatendend# app/views/user_mailer/welcome_email.html.erb<h1>Welcome <%= @user.name %>!</h1><p>Click here to get started: <%= link_to 'Get Started', @url %></p># app/views/user_mailer/welcome_email.text.erbWelcome <%= @user.name %>!Click here to get started: <%= @url %>
Mailer Testing
# spec/mailers/user_mailer_spec.rbRSpec.describe UserMailer, type: :mailer dodescribe '#welcome_email' dolet(:user) { create(:user, email: 'user@example.com') }let(:mail) { UserMailer.welcome_email(user) }it 'renders the subject' doexpect(mail.subject).to eq('Welcome to My App')endit 'renders the receiver email' doexpect(mail.to).to eq([user.email])endit 'renders the sender email' doexpect(mail.from).to eq(['notifications@example.com'])endit 'contains user name' doexpect(mail.body.encoded).to match(user.name)endendend
Mailer Previews (Rails 4.1+)
# test/mailers/previews/user_mailer_preview.rbclass UserMailerPreview < ActionMailer::Previewdef welcome_emailUserMailer.welcome_email(User.first)enddef password_resetuser = User.firsttoken = SecureRandom.urlsafe_base64UserMailer.password_reset(user, token)endend# Visit: http://localhost:3000/rails/mailers/user_mailer/welcome_email
Background Delivery
# Deliver later (asynchronous)UserMailer.welcome_email(@user).deliver_later# Deliver later with delayUserMailer.welcome_email(@user).deliver_later(wait: 1.hour)# Deliver later at specific timeUserMailer.welcome_email(@user).deliver_later(wait_until: Date.tomorrow.noon)# Deliver now (synchronous)UserMailer.welcome_email(@user).deliver_now
Background Job Conventions
ActiveJob Structure
# app/jobs/application_job.rbclass ApplicationJob < ActiveJob::Base# Global retry configurationretry_on StandardError, wait: :exponentially_longer, attempts: 5retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3# Discard specific errorsdiscard_on ActiveJob::DeserializationError# Global error handlingrescue_from(Exception) do |exception|ErrorTracker.notify(exception)raise exceptionendend# app/jobs/send_welcome_email_job.rbclass SendWelcomeEmailJob < ApplicationJobqueue_as :mailersdef perform(user)UserMailer.welcome_email(user).deliver_nowendend# UsageSendWelcomeEmailJob.perform_later(user)
Sidekiq-Specific Patterns
# app/jobs/process_order_job.rbclass ProcessOrderJob < ApplicationJobqueue_as :orders# Sidekiq-specific optionssidekiq_options retry: 3,backtrace: true,dead: truedef perform(order_id)order = Order.find(order_id)OrderProcessor.new(order).process!endend# config/sidekiq.yml:queues:- critical- default- mailers- low_priority:schedule:daily_cleanup:cron: '0 0 * * *' # Daily at midnightclass: DailyCleanupJob
Job Testing
# spec/jobs/send_welcome_email_job_spec.rbRSpec.describe SendWelcomeEmailJob, type: :job doinclude ActiveJob::TestHelperlet(:user) { create(:user) }it 'enqueues the job' doexpect {SendWelcomeEmailJob.perform_later(user)}.to have_enqueued_job(SendWelcomeEmailJob).with(user)endit 'sends welcome email' doexpect {perform_enqueued_jobs doSendWelcomeEmailJob.perform_later(user)end}.to change { ActionMailer::Base.deliveries.count }.by(1)endit 'retries on failure' doallow(UserMailer).to receive(:welcome_email).and_raise(StandardError)expect {SendWelcomeEmailJob.perform_later(user)}.to have_enqueued_job(SendWelcomeEmailJob).on_queue(:mailers)endend
Action Cable (WebSocket) Conventions
Channel Structure
# app/channels/application_cable/connection.rbmodule ApplicationCableclass Connection < ActionCable::Connection::Baseidentified_by :current_userdef connectself.current_user = find_verified_userendprivatedef find_verified_userif verified_user = User.find_by(id: cookies.encrypted[:user_id])verified_userelsereject_unauthorized_connectionendendendend# app/channels/chat_channel.rbclass ChatChannel < ApplicationCable::Channeldef subscribed# Stream from specific roomstream_from "chat_#{params[:room_id]}"# Or stream for current userstream_for current_userenddef unsubscribed# Cleanup when channel is unsubscribedstop_all_streamsenddef speak(data)# Receive data from clientmessage = current_user.messages.create!(content: data['message'],room_id: params[:room_id])# Broadcast to all subscribersActionCable.server.broadcast("chat_#{params[:room_id]}",message: render_message(message))endprivatedef render_message(message)ApplicationController.render(partial: 'messages/message',locals: { message: message })endend
Client-Side JavaScript
// app/javascript/channels/chat_channel.jsimport consumer from "./consumer"consumer.subscriptions.create({ channel: "ChatChannel", room_id: roomId },{connected() {console.log("Connected to chat")},disconnected() {console.log("Disconnected from chat")},received(data) {const messages = document.getElementById('messages')messages.insertAdjacentHTML('beforeend', data.message)},speak(message) {this.perform('speak', { message: message })}})
Broadcasting from Models
# app/models/message.rbclass Message < ApplicationRecordbelongs_to :userbelongs_to :roomafter_create_commit :broadcast_messageprivatedef broadcast_messagebroadcast_append_to([room, :messages],target: "messages",partial: "messages/message",locals: { message: self })endend
Cable Testing
# spec/channels/chat_channel_spec.rbRSpec.describe ChatChannel, type: :channel dolet(:user) { create(:user) }let(:room) { create(:room) }before dostub_connection(current_user: user)endit 'successfully subscribes' dosubscribe(room_id: room.id)expect(subscription).to be_confirmedexpect(subscription).to have_stream_from("chat_#{room.id}")endit 'broadcasts messages' dosubscribe(room_id: room.id)expect {perform :speak, message: 'Hello'}.to have_broadcasted_to("chat_#{room.id}")endend
Enhanced Concern Best Practices
When to Use Concerns
# GOOD: Truly shared behavior across unrelated models# app/models/concerns/publishable.rbmodule Publishableextend ActiveSupport::Concernincluded doscope :published, -> { where(published: true) }scope :draft, -> { where(published: false) }validates :published_at, presence: true, if: :published?enddef publish!update!(published: true, published_at: Time.current)enddef unpublish!update!(published: false, published_at: nil)endend# Used in multiple unrelated modelsclass Post < ApplicationRecordinclude Publishableendclass Video < ApplicationRecordinclude Publishableendclass Podcast < ApplicationRecordinclude Publishableend
Concern with Dependencies
# app/models/concerns/taggable.rbmodule Taggableextend ActiveSupport::Concernincluded do# Dependencies injectionhas_many :taggings, as: :taggable, dependent: :destroyhas_many :tags, through: :taggingsscope :tagged_with, ->(tag_name) {joins(:tags).where(tags: { name: tag_name })}end# Instance methodsdef tag_names=(names)self.tags = names.map { |n| Tag.find_or_create_by(name: n.strip) }enddef tag_namestags.pluck(:name)end# Class methodsclass_methods dodef most_tagged(limit = 10)select('taggable_id, COUNT(*) as tags_count').group('taggable_id').order('tags_count DESC').limit(limit)endendend
Controller Concerns
# app/controllers/concerns/error_handling.rbmodule ErrorHandlingextend ActiveSupport::Concernincluded dorescue_from ActiveRecord::RecordNotFound, with: :not_foundrescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entityrescue_from Pundit::NotAuthorizedError, with: :unauthorizedendprivatedef not_foundrespond_to do |format|format.html { render 'errors/404', status: :not_found }format.json { render json: { error: 'Not found' }, status: :not_found }endenddef unprocessable_entity(exception)respond_to do |format|format.html { render 'errors/422', status: :unprocessable_entity }format.json { render json: { errors: exception.record.errors }, status: :unprocessable_entity }endenddef unauthorizedrespond_to do |format|format.html { redirect_to root_path, alert: 'Not authorized' }format.json { render json: { error: 'Not authorized' }, status: :forbidden }endendend# Usageclass ApplicationController < ActionController::Baseinclude ErrorHandlingend
Method Visibility Rules
Public
# Callable from anywhere, defines the API# Controller actions must be public# Methods called from views must be public# Service interface methods# Rails context:# - Controller: only public methods are routable# - Model: public methods accessible from controllers/views# - Component: only public methods callable from templates
Private
# Can only be called within the class, without explicit receiver# Implementation details# Helper methods not part of public API# Methods that should never be called externally# Rails context:# - Controller: helper methods, before_action callbacks# - Service: internal computation methods# - Model: internal validation helpers# CRITICAL: Private methods CANNOT be called from outside the class.# If a view needs data, the component MUST have a public method.
Protected
# Callable from same class or subclasses# Methods meant for inheritance# Rare in typical Rails apps# Rails context:# - Occasionally in base controllers/models for shared behavior
Delegation Patterns
Using delegate
# Creates public forwarding methods# LIMITATION: Cannot delegate to private methods on targetdelegate :method1, :method2, to: :targetclass Component < ViewComponent::Basedelegate :total, :count, to: :@servicedef initialize(service:)@service = serviceendend# Now view can call component.total
Wrapper Methods
# Use when:# - Need to transform data# - Need to add caching# - Need different method names# - Need to handle errorsclass Component < ViewComponent::Basedef total@service.calculate_totalrescue ServiceError0endend
attr_reader Exposure
# Expose the underlying object directly# Use sparingly - breaks encapsulationclass Component < ViewComponent::Baseattr_reader :servicedef initialize(service:)@service = serviceendend# View calls: component.service.calculate_total
Rails Request Cycle
Request → Route → Controller#action→ Controller → Service/Model (business logic)→ Controller → sets @instance_variables→ Controller → renders View→ View → calls methods on @variables→ View → renders Components→ Component → accesses only its own methods
Key Insight: Each layer can only access what the previous layer explicitly provides. Views can't magically access service internals.
Implementation Order
Always implement in dependency order (bottom-up):
1. Database migrations (if needed)2. Models (foundation)3. Services (business logic)4. Components (presentation wrappers)5. Controllers (orchestration)6. Views (final layer)7. Tests (verify everything works)
Rationale: Each layer depends on the ones below it. Implementing bottom-up ensures dependencies exist before they're used.
Code Quality Standards
Method Size
- Maximum 15 lines per method
- Single responsibility per method
- Extract complex logic to private helper methods
Class Size
- Models: max 200 lines
- Controllers: max 100 lines
- Services: max 150 lines
Parameter Count
- Maximum 4 parameters
- Use keyword arguments for 2+ parameters
- Use Parameter Objects for complex cases
Form Objects (Expanded)
Basic Form Object
# app/forms/user_registration_form.rbclass UserRegistrationForminclude ActiveModel::Modelinclude ActiveModel::Attributesattribute :email, :stringattribute :password, :stringattribute :password_confirmation, :stringattribute :first_name, :stringattribute :last_name, :stringattribute :accept_terms, :booleanvalidates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }validates :password, presence: true, length: { minimum: 8 }validates :password_confirmation, presence: truevalidates :first_name, :last_name, presence: truevalidates :accept_terms, acceptance: truevalidate :passwords_matchdef savereturn false unless valid?ActiveRecord::Base.transaction do@user = User.create!(email: email,password: password,first_name: first_name,last_name: last_name)@profile = @user.create_profile!(full_name: "#{first_name} #{last_name}")SendWelcomeEmailJob.perform_later(@user)endtruerescue ActiveRecord::RecordInvalid => eerrors.add(:base, e.message)falseendattr_reader :user, :profileprivatedef passwords_matchreturn if password == password_confirmationerrors.add(:password_confirmation, "doesn't match password")endend# Controller usagedef create@form = UserRegistrationForm.new(registration_params)if @form.saveredirect_to @form.user, notice: 'Registration successful'elserender :new, status: :unprocessable_entityendend
Multi-Step Wizard Form
# app/forms/checkout_wizard.rbclass CheckoutWizardinclude ActiveModel::ModelSTEPS = [:shipping, :payment, :confirmation].freezeattr_accessor :current_stepattr_reader :orderdelegate :shipping_address, :billing_address, :payment_method,:shipping_address=, :billing_address=, :payment_method=,to: :ordervalidates :shipping_address, presence: true, if: :shipping_step?validates :payment_method, presence: true, if: :payment_step?def initialize(order, current_step: :shipping)@order = order@current_step = current_step.to_symenddef next_stepreturn if last_step?self.current_step = STEPS[STEPS.index(current_step) + 1]enddef previous_stepreturn if first_step?self.current_step = STEPS[STEPS.index(current_step) - 1]enddef savereturn false unless valid?order.saveenddef first_step?current_step == STEPS.firstenddef last_step?current_step == STEPS.lastendprivatedef shipping_step?current_step == :shippingenddef payment_step?current_step == :paymentendend
Decorators (Expanded)
Draper Decorator Pattern
# Gemfilegem 'draper'# app/decorators/application_decorator.rbclass ApplicationDecorator < Draper::Decoratordelegate_alldef created_ath.content_tag(:time, object.created_at.strftime("%B %d, %Y"),datetime: object.created_at.iso8601)endend# app/decorators/user_decorator.rbclass UserDecorator < ApplicationDecoratordef full_name"#{object.first_name} #{object.last_name}"enddef profile_linkh.link_to full_name, h.user_path(object), class: 'user-link'enddef avatarif object.avatar.attached?h.image_tag object.avatar.variant(resize_to_limit: [100, 100])elseh.image_tag 'default-avatar.png', alt: full_nameendenddef status_badgecss_class = object.active? ? 'badge-success' : 'badge-secondary'status_text = object.active? ? 'Active' : 'Inactive'h.content_tag(:span, status_text, class: "badge #{css_class}")enddef member_since"Member since #{object.created_at.strftime('%B %Y')}"endend# Controller usagedef show@user = User.find(params[:id]).decorateend# View usage<%= @user.profile_link %><%= @user.avatar %><%= @user.status_badge %>
SimpleDelegator Pattern (Without Gems)
# app/decorators/user_decorator.rbclass UserDecorator < SimpleDelegatordef initialize(user, view_context)super(user)@view_context = view_contextenddef full_name"#{first_name} #{last_name}"enddef profile_linkh.link_to full_name, h.user_path(self)enddef formatted_created_atcreated_at.strftime("%B %d, %Y")endprivatedef h@view_contextendend# Controllerdef showuser = User.find(params[:id])@user = UserDecorator.new(user, view_context)end
Presenters (Expanded)
View-Specific Presenter
# app/presenters/dashboard_presenter.rbclass DashboardPresenterdef initialize(user, view_context)@user = user@view_context = view_contextenddef welcome_messagetime_of_day = Time.current.hour < 12 ? 'Morning' : 'Afternoon'"Good #{time_of_day}, #{@user.first_name}!"enddef recent_orders@recent_orders ||= @user.orders.recent.limit(5).map do |order|OrderPresenter.new(order, @view_context)endenddef total_spenth.number_to_currency(@user.orders.sum(:total))enddef activity_feed@user.activities.recent.limit(10).map do |activity|{icon: activity_icon(activity),text: activity_text(activity),time: h.time_ago_in_words(activity.created_at)}endenddef stats{total_orders: @user.orders.count,total_spent: total_spent,favorite_category: @user.favorite_category&.name || 'N/A',member_since: @user.created_at.year}endprivatedef h@view_contextenddef activity_icon(activity)case activity.actionwhen 'order_placed' then 'shopping-cart'when 'review_posted' then 'star'when 'profile_updated' then 'user'else 'activity'endenddef activity_text(activity)case activity.actionwhen 'order_placed'"You placed order ##{activity.target_id}"when 'review_posted'"You reviewed #{activity.target.product.name}"when 'profile_updated'"You updated your profile"endendend# Controllerdef dashboard@presenter = DashboardPresenter.new(current_user, view_context)end# View<h1><%= @presenter.welcome_message %></h1><div class="stats"><% @presenter.stats.each do |key, value| %><div class="stat"><span class="label"><%= key.to_s.humanize %></span><span class="value"><%= value %></span></div><% end %></div>
Collection Presenter
# app/presenters/users_index_presenter.rbclass UsersIndexPresenterdef initialize(users, view_context, filters: {})@users = users@view_context = view_context@filters = filtersenddef users@decorated_users ||= @users.map { |u| UserDecorator.new(u, h) }enddef total_count@users.total_countenddef paginationh.paginate(@users)enddef active_filters@filters.select { |_, v| v.present? }enddef filter_summaryreturn "All users" if active_filters.empty?parts = []parts << "Role: #{@filters[:role]}" if @filters[:role]parts << "Status: #{@filters[:status]}" if @filters[:status]parts.join(', ')enddef export_linkh.link_to 'Export CSV', h.users_path(format: :csv, **@filters),class: 'btn btn-secondary'endprivatedef h@view_contextendend
Repository Pattern
Basic Repository
# app/repositories/user_repository.rbclass UserRepositoryclass << selfdef find(id)User.find(id)enddef find_by_email(email)User.find_by(email: email)enddef active_usersUser.where(active: true).order(created_at: :desc)enddef search(query)User.where('name ILIKE ? OR email ILIKE ?', "%#{query}%", "%#{query}%")enddef with_recent_orders(days: 30)User.joins(:orders).where('orders.created_at > ?', days.days.ago).distinctenddef create(attributes)User.create(attributes)enddef update(user, attributes)user.update(attributes)enddef destroy(user)user.destroyendendend# Service using repositoryclass UserRegistrationServicedef initialize(repository: UserRepository)@repository = repositoryenddef call(attributes)user = @repository.create(attributes)if user.persisted?SendWelcomeEmailJob.perform_later(user)Result.success(user)elseResult.failure(user.errors)endendend
Repository with Complex Queries
# app/repositories/order_repository.rbclass OrderRepositoryclass << selfdef pending_ordersOrder.where(status: 'pending').order(created_at: :asc)enddef overdue_orders(threshold: 3.days)Order.where(status: 'pending').where('created_at < ?', threshold.ago)enddef user_orders(user, status: nil)scope = user.ordersscope = scope.where(status: status) if status.present?scope.order(created_at: :desc)enddef revenue_by_month(year: Time.current.year)Order.where(status: 'completed').where('EXTRACT(YEAR FROM created_at) = ?', year).group("DATE_TRUNC('month', created_at)").sum(:total)enddef top_customers(limit: 10)User.joins(:orders).where(orders: { status: 'completed' }).group('users.id').select('users.*, SUM(orders.total) as total_spent').order('total_spent DESC').limit(limit)endendend
PORO (Plain Old Ruby Object) Conventions
Value Objects
# app/models/money.rbclass Moneyinclude Comparableattr_reader :amount, :currencydef initialize(amount, currency: 'USD')@amount = BigDecimal(amount.to_s)@currency = currencyenddef +(other)validate_currency!(other)Money.new(amount + other.amount, currency: currency)enddef -(other)validate_currency!(other)Money.new(amount - other.amount, currency: currency)enddef *(multiplier)Money.new(amount * multiplier, currency: currency)enddef <=>(other)validate_currency!(other)amount <=> other.amountenddef to_sformat('%s%.2f', currency_symbol, amount)enddef ==(other)amount == other.amount && currency == other.currencyendprivatedef validate_currency!(other)return if currency == other.currencyraise ArgumentError, "Cannot operate on different currencies"enddef currency_symbolcase currencywhen 'USD' then '$'when 'EUR' then '€'when 'GBP' then '£'else currencyendendend# Usageprice = Money.new(19.99)tax = price * 0.08total = price + tax # => $21.59
Data Transfer Objects (DTOs)
# app/models/user_dto.rbclass UserDTOattr_reader :id, :email, :full_name, :roledef initialize(id:, email:, full_name:, role:)@id = id@email = email@full_name = full_name@role = roleenddef self.from_model(user)new(id: user.id,email: user.email,full_name: "#{user.first_name} #{user.last_name}",role: user.role)enddef to_h{id: id,email: email,full_name: full_name,role: role}endend# Or using Ruby 3.2+ Data classUserDTO = Data.define(:id, :email, :full_name, :role) dodef self.from_model(user)new(id: user.id,email: user.email,full_name: "#{user.first_name} #{user.last_name}",role: user.role)endend
Result Objects
# app/models/result.rbclass Resultattr_reader :value, :errordef initialize(success:, value: nil, error: nil)@success = success@value = value@error = errorenddef self.success(value = nil)new(success: true, value: value)enddef self.failure(error)new(success: false, error: error)enddef success?@successenddef failure?!@successenddef on_successyield value if success?selfenddef on_failureyield error if failure?selfendend# Service using Result objectclass CreateUserServicedef call(params)user = User.new(params)if user.saveResult.success(user)elseResult.failure(user.errors)endendend# Usageresult = CreateUserService.new.call(user_params)result.on_success { |user| redirect_to user }.on_failure { |errors| render :new }
Policy Objects
# app/policies/post_visibility_policy.rbclass PostVisibilityPolicydef initialize(user, post)@user = user@post = postenddef visible?return true if @post.published?return true if @user&.admin?return true if @post.user_id == @user&.idfalseenddef editable?return true if @user&.admin?return true if @post.user_id == @user&.idfalseendend# Usage in controllerdef show@post = Post.find(params[:id])policy = PostVisibilityPolicy.new(current_user, @post)unless policy.visible?redirect_to root_path, alert: 'Not authorized'endend
Quick Reference
Before Writing Any Code
# Check existing patternsls app/services/ls app/models/grep -r 'class.*Service' app/ --include='*.rb' -l | head -10# Check naming conventionshead -30 $(find app/services -name '*.rb' | head -1)# Check dependenciescat Gemfile | grep -v '^#' | grep -v '^$'
Common File Locations
app/models/ - ActiveRecord modelsapp/controllers/ - Controllersapp/services/ - Service objectsapp/components/ - ViewComponentsapp/queries/ - Query objectsapp/forms/ - Form objectsapp/presenters/ - Presentersapp/decorators/ - Decoratorsapp/serializers/ - API serializersapp/jobs/ - Background jobsapp/mailers/ - Action Mailersapp/channels/ - Action Cable channelsapp/repositories/ - Repository pattern objectsapp/policies/ - Policy objects (business rules)