| name | rails |
| description | - Building or maintaining a Rails 7+ application |
Ruby on Rails 7+
When to activate
- Building or maintaining a Rails 7+ application
- Implementing ActiveRecord models, associations, or query optimization
- Adding Hotwire (Turbo + Stimulus) for reactive UI without heavy JavaScript
- Setting up Sidekiq background jobs
- Configuring Devise for authentication
- Writing RSpec request specs with FactoryBot
- Deploying with Kamal
When NOT to use
- Pure Ruby scripting with no Rails involvement
- API-only builds where Turbo/Stimulus are explicitly excluded
- Rails 6 or older codebases with significantly different conventions (check Rails version first)
Instructions
N+1 Query Detection and Fix
Add the bullet gem to development:
gem "bullet", group: :development
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
Bullet.rails_logger = true
Bullet.add_footer = true
end
Fix detected N+1s with includes (or preload/eager_load when you need to filter on the association):
Post.all.each { |p| p.author.name }
Post.includes(:author).each { |p| p.author.name }
Post.eager_load(:author).where(authors: { active: true })
Post.includes(:author).select("posts.id, posts.title, authors.name")
Add query count assertions to request specs to catch regressions:
expect { get "/posts" }.to make_database_queries(count: 2)
Turbo Frames vs Turbo Streams
Turbo Frames replace a bounded region of the page with the server response. One frame per request.
<%# app/views/posts/index.html.erb %>
<%= turbo_frame_tag "posts_list" do %>
<%= render @posts %>
<% end %>
<%# Clicking this link replaces the frame content, not the whole page %>
<%= link_to "Load more", posts_path(page: 2), data: { turbo_frame: "posts_list" } %>
Turbo Streams push multiple fine-grained DOM mutations from one response (or from ActionCable). Use when a single action must update several parts of the page simultaneously.
def create
@post = Post.create!(post_params)
respond_to do |format|
format.turbo_stream
format.html { redirect_to posts_path }
end
end
<%# app/views/posts/create.turbo_stream.erb %>
<%= turbo_stream.prepend "posts_list", partial: "posts/post", locals: { post: @post } %>
<%= turbo_stream.replace "post_count", partial: "posts/count" %>
<%= turbo_stream.update "flash", partial: "shared/flash" %>
Decision rule: use Frames for simple navigation replacements; use Streams when one action mutates multiple DOM targets or when broadcasting updates over ActionCable.
Stimulus Controller Lifecycle
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["menu"]
static values = { open: Boolean }
connect() { }
disconnect() { }
toggle() {
this.openValue = !this.openValue
}
openValueChanged(value) {
this.menuTarget.hidden = !value
}
}
Wire up in HTML:
<div data-controller="toggle">
<button data-action="click->toggle#toggle">Menu</button>
<nav data-toggle-target="menu" hidden>...</nav>
</div>
Keep controllers small and composable. One controller per behavior unit, not per page.
Sidekiq Job Structure and Retry Config
class InvoiceMailerJob
include Sidekiq::Job
sidekiq_options queue: :mailers, retry: 5, backtrace: true
sidekiq_retries_exhausted do |msg, ex|
Sentry.capture_exception(ex, extra: { job: msg })
end
def perform(invoice_id)
invoice = Invoice.find(invoice_id)
InvoiceMailer.with(invoice:).receipt.deliver_now
end
end
InvoiceMailerJob.perform_async(invoice.id)
InvoiceMailerJob.perform_in(5.minutes, invoice.id)
InvoiceMailerJob.set(queue: :critical).perform_async(invoice.id)
Configure retry back-off in config/initializers/sidekiq.rb:
Sidekiq.configure_server do |config|
config.redis = { url: ENV.fetch("REDIS_URL") }
end
Sidekiq.configure_client do |config|
config.redis = { url: ENV.fetch("REDIS_URL") }
end
Sidekiq Pro/Enterprise is needed for true unique jobs — for open-source, use sidekiq-unique-jobs.
Credentials Management
Rails 7 uses per-environment credentials. Edit with:
EDITOR=vim rails credentials:edit --environment production
Access in code:
Rails.application.credentials.dig(:stripe, :secret_key)
Rails.application.credentials.sendgrid_api_key!
Never commit config/master.key or config/credentials/production.key. Add them to .gitignore and supply via environment variable (RAILS_MASTER_KEY) in CI and deployment.
RSpec Request Specs
Request specs test the full HTTP stack without mounting the full Rack server. Prefer them over controller specs.
require "rails_helper"
RSpec.describe "Posts", type: :request do
let(:user) { create(:user) }
before { sign_in user }
describe "GET /posts" do
let!(:posts) { create_list(:post, 3, author: user) }
it "returns published posts" do
get posts_path
expect(response).to have_http_status(:ok)
expect(response.body).to include(posts.first.title)
end
end
describe "POST /posts" do
context "with valid params" do
it "creates a post and redirects" do
expect {
post posts_path, params: { post: attributes_for(:post) }
}.to change(Post, :count).by(1)
expect(response).to redirect_to(Post.last)
end
end
context "with invalid params" do
it "renders unprocessable entity" do
post posts_path, params: { post: { title: "" } }
expect(response).to have_http_status(:unprocessable_entity)
FactoryBot conventions:
- One factory per model in
spec/factories/
- Use
create for DB-persisted records, build for in-memory
- Use
create_list for collections
- Use
attributes_for to get a plain hash for params
Kamal deploy.yml Structure
service: myapp
image: registry.example.com/myapp
servers:
web:
hosts:
- 203.0.113.10
labels:
traefik.http.routers.myapp.rule: Host(`myapp.example.com`)
worker:
hosts:
- 203.0.113.11
cmd: bundle exec sidekiq
registry:
server: registry.example.com
username:
- KAMAL_REGISTRY_USERNAME
password:
- KAMAL_REGISTRY_PASSWORD
env:
clear:
RAILS_ENV: production
WEB_CONCURRENCY: "2"
secret:
- RAILS_MASTER_KEY
- DATABASE_URL
- REDIS_URL
accessories:
db:
image: postgres:16
host: 203.0.113.10
port:
Deploy:
kamal setup
kamal deploy
kamal rollback
kamal app logs -f
Example
Adding a CommentJob that posts a Slack notification after a comment is created, tested end-to-end:
class CommentNotifyJob
include Sidekiq::Job
sidekiq_options queue: :default, retry: 3
def perform(comment_id)
comment = Comment.includes(:post, :author).find(comment_id)
SlackNotifier.post(
channel: "#activity",
text: "#{comment.author.name} commented on #{comment.post.title}"
)
end
end
after_create_commit -> { CommentNotifyJob.perform_async(id) }
RSpec.describe CommentNotifyJob, type: :job do
it "posts to Slack" do
comment = create(:comment)
allow(SlackNotifier).to receive(:post)
described_class.new.perform(comment.id)
expect(SlackNotifier).to have_received(:post).with(
hash_including(channel: "#activity")
)
end
end