nosia
CLAUDE.md211 starsChanged 5 months agoMITView on GitHub
No app/services/, app/queries/, app/policies/, app/forms/ directories. Business logic lives in models. Authorization via roles on User model + controller concerns. Complex forms use standard Rails nested attributes.
In this repository17 other files it gives its agents.
- AGENTS.mdAGENTS.md
- .opencode/CLAUDE.mdCLAUDE.md
- api-patternsSkill
- auth-setupSkill
- caching-patternsSkill
- concern-patternsSkill
- crud-patternsSkill
- event-trackingSkill
- job-patternsSkill
- mailer-patternsSkill
- migration-patternsSkill
- model-patternsSkill
- multi-tenant-setupSkill
- state-recordsSkill
- stimulus-patternsSkill
- testing-patternsSkill
- turbo-patternsSkill
The file
Show its rules and text
We split this CLAUDE.md into the rules it gives an agent
25 rules. About 0 are common, 20 are distinctive, and 5 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈20
Distinctive · estimate
This author's own choices. Worth a look.
5
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 34
State as records: No boolean flags for business state -- use has_one state records
- Distinctive: line 60
Write a failing Minitest test with fixtures
- Very specific: line 81
No newline under private/protected keyword.
- Distinctive: line 82
Avoid service objects -- domain logic belongs in models with concerns (services acceptable when justified)
- Distinctive: line 110
Every piece of code should be as simple as possible.
- Distinctive: line 114
No abstractions until complexity actually demands them
- Very specific: line 115
Prefer rails generate scaffold over hand-rolled architectures
- Distinctive: line 116
If a junior developer can't understand it in 30 seconds, simplify it
- Distinctive: line 122
Every piece of knowledge must have a single, authoritative representation.
- Distinctive: line 137
Don't introduce service objects until a controller action exceeds ~10 lines of business logic
- Distinctive: line 138
Don't create microservices until the monolith proves insufficient
- Distinctive: line 139
Don't add configuration options for hypothetical future requirements
- Distinctive: line 140
Don't build abstractions for one-time operations
- Distinctive: line 189
No client should depend on methods it does not use.
- Distinctive: line 231
Every piece of data or logic lives in exactly one place.
- Distinctive: line 248
When your code surprises experienced Rails developers, reconsider.
- Distinctive: line 555
RED: Write a failing test that describes the desired behavior
- Distinctive: line 556
GREEN: Write the minimum code to make the test pass
- Distinctive: line 583
Factory over fixture: Use FactoryBot for flexible test data
- Very specific: line 584
Avoid
let!: Prefer explicit setup in each test for clarity - Distinctive: line 692
Use the Bullet gem to detect N+1 queries automatically in development and test.
- Distinctive: line 759
Rule of thumb: If the behavior is a property of the model (soft-delete, timestamps, search), use a concern.
- Distinctive: line 770
Rule of thumb: If more than 20% of columns are subtype-specific, don't use STI.
- Very specific: line 800
Always-true data transformations: before_save :downcase_email
- Distinctive: line 851
Don't create services for simple CRUD.
≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
37signals Rails Configuration
Tech Stack
- Ruby 3.3, Rails 8.2 (edge), MySQL (SaaS) / SQLite (OSS)
- Frontend: Hotwire (Turbo 8 + Stimulus 3.2), Importmap (no Node.js)
- Testing: Minitest + Fixtures (not RSpec, not FactoryBot)
- Auth: Custom passwordless: passkeys (WebAuthn) + magic links (no Devise),
has_secure_passwordoptional - Background Jobs: Solid Queue (database-backed, no Redis)
- Caching: Solid Cache | WebSockets: Solid Cable
- IDs: UUIDv7 everywhere (base36-encoded, 25-char strings)
- Assets: Propshaft + Import Maps (no Node.js, no Webpack)
- Deployment: Kamal 2 + Thruster
Architecture
app/
controllers/ # Thin. Only 7 REST actions. New resource for each state change.
models/ # Rich. Business logic, concerns, associations, validations.
models/concerns/ # Horizontal behavior: Closeable, Assignable, Searchable.
views/ # ERB + Turbo Frames/Streams. No JS frameworks.
jobs/ # Shallow. Call model methods, don't contain logic.
mailers/ # Minimal. Bundle notifications, plain-text first.
channels/ # ActionCable channels for real-time.No app/services/, app/queries/, app/policies/, app/forms/ directories. Business logic lives in models. Authorization via roles on User model + controller concerns. Complex forms use standard Rails nested attributes.
Core Philosophy
- Vanilla Rails: Rich domain models, thin controllers, avoid service objects (acceptable when justified, but not as default architecture), no Devise, no Pundit
- Everything is CRUD: State changes = new resources (
Closure,Publication,Archival) - State as records: No boolean flags for business state -- use
has_onestate records - Rich models over services: Business logic lives in models, organized via concerns
- Concerns for organization: Models compose behavior via focused concerns (
Closeable,Assignable) - No foreign key constraints: Application enforces referential integrity
- Multi-tenancy: URL path-based (
/:account_id/...),account_idon every table - Current for context:
Current.user,Current.account,Current.session - Shallow jobs:
_later/_nowpattern; job calls model method, logic stays in model
Key Commands
bin/setup # Initial setup
bin/dev # Start dev server
bin/rails test # Full test suite
bin/rails test test/models/card_test.rb # Specific file
bin/rails test test/models/card_test.rb:14 # Specific line
bin/rails test:system # System tests (Capybara + Selenium)
bin/ci # Full CI (rubocop + brakeman + tests)
bundle exec rubocop -a # Auto-fix Ruby style
bin/rails db:migrate # Run migrations
bin/rails db:fixtures:load # Load fixture data
bin/rails db:reset # Drop, create, load schema + fixturesDevelopment Workflow
- Write a failing Minitest test with fixtures
- Implement minimal code to make it pass
- Refactor while tests stay green
Naming Conventions
| Layer | Pattern | Example |
| Model | Singular PascalCase | Card, BoardPublication |
| Controller | Plural, nested by resource | Cards::ClosuresController |
| State Record | Noun describing state | Closure, Publication, Goldness |
| Concern | Adjective/-able | Closeable, Assignable, Searchable |
| Job | Model::VerbJob | Event::RelayJob, Card::CleanupJob |
| Test | ModelTest / ControllerTest | CardTest, Cards::ClosuresControllerTest |
Style Guide (37signals)
- Expanded conditionals over guard clauses (exception: single-line early returns at method start)
- Method ordering: class methods > public instance (
initializefirst) > private - Order private methods by invocation flow (call order)
- Bang methods (
!) only when a non-bang counterpart exists - No newline under
private/protectedkeyword; indent content under it - Avoid service objects -- domain logic belongs in models with concerns (services acceptable when justified)
belongs_to :creator, default: -> { Current.user }for context defaultstouch: trueon child associations for cache invalidation
Rails Development Principles (2026)
A comprehensive guide to modern development principles applied to Ruby on Rails applications. Covers universal software principles, Rails-specific philosophy, architecture patterns, testing strategy, security, and performance.
Table of Contents
- Universal Software Principles
- The Rails Doctrine
- Modern Rails 8 Stack
- Architecture & Code Organization
- Testing Strategy
- Security Principles
- Performance Principles
- Settled Debates
- Anti-Patterns to Avoid
1. Universal Software Principles
KISS -- Keep It Simple
Every piece of code should be as simple as possible. There is no value in a "smart" solution -- only in an easily understandable one.
In Rails, this means:
- Standard CRUD controllers with conventional routing
- No abstractions until complexity actually demands them
- Prefer
rails generate scaffoldover hand-rolled architectures - If a junior developer can't understand it in 30 seconds, simplify it
DHH's Rails World 2025 keynote was explicitly a "call for radical simplicity." The industry has built unnecessarily complicated solutions around fundamentally simple CRUD operations.
DRY -- Don't Repeat Yourself
Every piece of knowledge must have a single, authoritative representation.
Rails enforces this via:
- Schema-derived migrations (database is the source of truth)
- Convention-based naming (
Usermodel ->userstable ->UsersController->/usersroutes) - Concerns for shared model behavior
- Partials and ViewComponents for shared UI
- Service objects for shared business logic
Caveat: DRY is about knowledge, not code. Three similar lines are better than a premature abstraction. Duplicate code is cheaper than the wrong abstraction.
YAGNI -- You Ain't Gonna Need It
Implement only what is currently required.
- Don't introduce service objects until a controller action exceeds ~10 lines of business logic
- Don't create microservices until the monolith proves insufficient
- Don't add configuration options for hypothetical future requirements
- Don't build abstractions for one-time operations
The right amount of complexity is the minimum needed for the current task. Start simple, extract later.
SOLID
Single Responsibility Principle (SRP) Each class has one reason to change. A model handles persistence, a service handles business logic, a controller handles HTTP.
# BAD: Model does everything
class Order < ApplicationRecord
def process_payment # payment logic
def send_confirmation # email logic
def update_inventory # inventory logic
end
# GOOD: Each class has one job
class Order < ApplicationRecord
# validations, associations, scopes only
end
class ProcessOrder
def call(order)
PaymentService.charge(order)
OrderMailer.confirmation(order).deliver_later
InventoryService.update(order)
end
endOpen/Closed Principle (OCP) Open for extension, closed for modification. Add new behavior via inheritance, modules, or composition.
# Add new behavior without modifying existing code
module Trackable
extend ActiveSupport::Concern
included do
has_many :activities, as: :trackable
after_create :track_creation
end
endLiskov Substitution Principle (LSP) Subclasses must be usable wherever the parent class is expected. STI subclasses should not break parent behavior.
Interface Segregation Principle (ISP) No client should depend on methods it does not use. In Ruby, use focused concerns and modules rather than god objects.
Dependency Inversion Principle (DIP) High-level modules should not depend on low-level modules. Both depend on abstractions.
# BAD: Hard-coded dependency
class OrderProcessor
def call(order)
StripeGateway.new.charge(order.total)
end
end
# GOOD: Injected dependency
class OrderProcessor
def initialize(gateway: StripeGateway.new)
@gateway = gateway
end
def call(order)
@gateway.charge(order.total)
end
endSeparation of Concerns
Each component handles one aspect of the system. Rails MVC is the primary expression:
| Layer | Responsibility |
| Controller | HTTP handling, parameter validation, response rendering |
| Model | Data persistence, validations, associations, simple domain logic |
| View | Presentation markup |
| Service | Multi-step business operations |
| Query | Complex database queries |
| Policy | Authorization rules |
| Presenter | Display formatting |
| Component | Reusable UI elements |
Single Source of Truth
Every piece of data or logic lives in exactly one place.
- Database schema is the source of truth for data structure
db/schema.rbreflects the current database state- Validations live on the model, not duplicated in controllers
- Business rules live in services, not scattered across callbacks
- Authorization rules live in policies, not spread across controllers
Principle of Least Surprise
Software behavior should match developer expectations. Rails Convention over Configuration is a direct expression:
Usermodel maps touserstableUsersControllerhandles/usersroutesuser_path(@user)generates/users/1app/models/user.rbis whereUserlives
When your code surprises experienced Rails developers, reconsider.
2. The Rails Doctrine
The Nine Pillars
- Optimize for programmer happiness -- Developer experience matters. Ruby's expressiveness and Rails' conventions serve this goal.
- Convention over Configuration -- Naming conventions, directory structure, and sensible defaults eliminate boilerplate.
- The menu is omakase -- Rails ships a curated, opinionated stack. Trust the framework's choices.
- No one paradigm -- Use OOP, functional patterns, metaprogramming -- whatever fits the problem.
- Exalt beautiful code -- Code is read far more than written. Readability is a feature.
- Provide sharp knives -- Trust developers with powerful tools rather than restricting them.
- Value integrated systems -- A full-stack framework beats a collection of libraries.
- Progress over stability -- Rails evolves. Embrace change over backwards compatibility.
- Push up a big tent -- The community is diverse. There's room for different approaches.
The Majestic Monolith
DHH's position in 2025 is stronger than ever:
"The majestic monolith remains undefeated for the vast majority of web apps. Replacing method calls with network calls makes everything harder, slower, and more brittle. It should be the absolute last resort."
Even GitHub and Shopify run monoliths with millions of lines of code. The 2025 industry trend shows enterprises returning from microservices to modular monoliths.
When the monolith works (almost always):
- Method calls are ~1000x faster than network calls
- Shared database transactions are trivial
- Refactoring is IDE-assisted, not cross-service negotiation
- Debugging follows a single stack trace
- Deployment is one unit
When to extract (rarely):
- A component has fundamentally different scaling needs (e.g., video transcoding)
- Team size exceeds ~50 engineers on the same codebase
- Regulatory isolation is required
"We're All CRUD Monkeys"
DHH openly states: "I'm a CRUD monkey. My career is CRUD monkeying." Most web applications are fundamentally CRUD operations with varying levels of business logic. Embrace this reality rather than adding complexity to avoid it.
3. Modern Rails 8 Stack
The Omakase Stack (2026)
| Layer | Tool | Replaces |
| Frontend | Hotwire (Turbo + Stimulus) | React, Vue, Angular |
| Background Jobs | Solid Queue | Sidekiq + Redis |
| Caching | Solid Cache | Redis |
| WebSockets | Solid Cable | Redis |
| Asset Pipeline | Propshaft + Import Maps | Webpack, esbuild |
| Deployment | Kamal 2 + Thruster | Heroku, Capistrano |
| CSS | Tailwind CSS | Bootstrap, custom CSS |
| Components | ViewComponent | Partials |
Hotwire-First Development
Build reactive UIs via server-rendered HTML rather than heavy JavaScript frameworks:
- Turbo Drive: SPA-like page transitions without JavaScript
- Turbo Frames: Partial page updates by scoping navigation to frames
- Turbo Streams: Real-time updates via WebSocket or HTTP responses
- Stimulus: Modest JavaScript behaviors attached to HTML via data attributes
Rule: reach for Turbo Frames before Stimulus, Stimulus before custom JavaScript, and never for a JavaScript framework.
The Solid Trifecta
Rails 8 eliminates Redis as a dependency:
- Solid Queue: Database-backed job processing. Supports priorities, concurrency controls, recurring jobs. Production-tested at 37signals.
- Solid Cache: Database-backed caching with FIFO eviction. Leverages NVMe SSDs -- fast enough for most applications.
- Solid Cable: Database-backed Action Cable adapter. Good enough for most WebSocket use cases.
Benefit: one fewer infrastructure dependency, simpler deployment, easier development setup.
No Build Philosophy
- Propshaft: Simple asset pipeline, no compilation step
- Import Maps: Pin JavaScript dependencies without bundling
- No Node.js required: The entire Rails stack runs on Ruby alone
Kamal 2 Deployment
- Zero-downtime deploys over HTTP/2 with SSL
- Thruster proxy handles compression, caching, SSL termination
- Docker-based, cloud-agnostic (runs on any Linux server)
- "No PaaS required" -- deploy to $5/month VPS or $300 mini-PCs
Rails 8.1 Additions
- Native Markdown rendering
- Active Job Continuations (pausable background tasks)
- Local CI testing
- Turbo Offline support
- Action Push (push notifications)
4. Architecture & Code Organization
The Layer Cake
HTTP Request
|
v
[Controller] Thin. Delegates to services. Renders responses.
|
v
[Service] Business logic. Orchestrates models, APIs, side effects.
|
+---> [Query] Complex reads. Returns scopes or arrays.
+---> [Policy] Authorization. Returns true/false.
+---> [Form] Multi-model validation. Returns valid/invalid.
|
v
[Model] Persistence. Validations. Associations. Simple logic.
|
v
[Database]For the view layer:
[Controller]
|
v
[View/Template] ERB markup only. No logic.
|
+---> [Presenter] Format data for display.
+---> [Component] Reusable UI element with tests.
+---> [Helper] Simple view utilities (use sparingly).Directory Structure
app/
controllers/ # HTTP handling, thin
models/ # ActiveRecord, persistence, validations
views/ # ERB templates, no logic
services/ # Business logic, orchestration
queries/ # Complex database queries
forms/ # Multi-model form objects
policies/ # Pundit authorization
presenters/ # View formatting (SimpleDelegator)
components/ # ViewComponents (reusable UI)
jobs/ # Background jobs (Solid Queue)
mailers/ # Email deliveryWhen to Extract
| Pattern | Extract When | Don't Extract When |
| Service Object | Controller action exceeds ~10 lines of business logic, logic spans multiple models, external API calls, needs isolated testing | Simple CRUD, single-model operations, logic fits naturally in the model |
| Query Object | Query joins multiple tables, has conditional clauses, is reused across controllers | Single-table scope, one-liner query |
| Form Object | Form touches multiple models, has custom validation logic, wizard/multi-step forms | Single-model form, standard validations |
| Presenter | Model has display formatting methods, conditional display logic | Simple attribute display |
| ViewComponent | UI element is reused across views, needs unit testing, has complex markup | One-off view snippet |
| Concern | Behavior is shared across multiple models, is simple and narrowly focused | Logic belongs to one model, complex behavior |
| Policy | Authorization logic, role-based access | No access control needed |
Skinny Everything
The original "Skinny Controllers, Fat Models" mantra has evolved. The 2025 consensus is Skinny Everything:
# Controller: thin, delegates
class OrdersController < ApplicationController
def create
result = CreateOrder.call(order_params, current_user)
if result.success?
redirect_to result.order, notice: "Order placed"
else
@order = result.order
render :new, status: :unprocessable_entity
end
end
end
# Service: focused business logic
class CreateOrder
def self.call(params, user)
order = user.orders.build(params)
return Result.failure(order) unless order.valid?
order.save!
ProcessPaymentJob.perform_later(order)
Result.success(order)
end
end
# Model: persistence and domain rules
class Order < ApplicationRecord
belongs_to :user
has_many :line_items, dependent: :destroy
validates :total, numericality: { greater_than: 0 }
scope :recent, -> { where("created_at > ?", 30.days.ago) }
endService Object Pattern
class CreateOrder
Result = Data.define(:success?, :order, :errors)
def self.call(...)
new(...).call
end
def initialize(params, user)
@params = params
@user = user
end
def call
order = @user.orders.build(@params)
if order.save
notify(order)
Result.new(success?: true, order: order, errors: [])
else
Result.new(success?: false, order: order, errors: order.errors.full_messages)
end
end
private
def notify(order)
OrderMailer.confirmation(order).deliver_later
end
endKey conventions:
.callclass method as the public interface- Return a Result object (success/failure + data)
- One public method per service
- Private methods for sub-steps
- Inject dependencies for testability
Query Object Pattern
class ActiveUsersQuery
def initialize(relation = User.all)
@relation = relation
end
def call(since: 30.days.ago)
@relation
.joins(:sessions)
.where(sessions: { created_at: since.. })
.distinct
.order(last_sign_in_at: :desc)
end
end
# Usage
ActiveUsersQuery.new.call(since: 7.days.ago)
ActiveUsersQuery.new(User.admin).call # composable5. Testing Strategy
The Testing Pyramid
/ System \ Few: complete user workflows
/ (Capybara) \ Slow, expensive, high confidence
/________________\
/ Integration \ Some: HTTP request/response cycles
/ (Request Specs) \ Medium speed, good coverage
/______________________\
/ Unit Tests \ Many: models, services, policies
/ (Model, Service Specs) \ Fast, focused, cheap
/______________________________\What to Test
| Layer | Test Type | What to Assert |
| Model | Unit | Validations, associations, scopes, instance methods |
| Service | Unit | Success/failure paths, side effects, edge cases |
| Controller | Request | HTTP status, redirects, flash, response body |
| Policy | Unit | Every action for every role |
| Query | Unit | Correct results, edge cases, empty states |
| Component | Unit | Rendered HTML, slots, variants |
| Mailer | Unit | Recipients, subject, body content |
| Job | Unit | Enqueuing, idempotency, error handling |
| System | Integration | Critical user journeys only |
Red-Green-Refactor
- RED: Write a failing test that describes the desired behavior
- GREEN: Write the minimum code to make the test pass
- REFACTOR: Improve the code structure while keeping tests green
# 1. RED: Write the failing test
RSpec.describe CreateOrder do
it "creates an order and enqueues confirmation email" do
user = create(:user)
params = attributes_for(:order)
result = described_class.call(params, user)
expect(result).to be_success
expect(result.order).to be_persisted
expect(OrderMailer).to have_enqueued_mail(:confirmation)
end
end
# 2. GREEN: Write minimal implementation
# 3. REFACTOR: Clean up while tests stay greenTesting Principles
- Test behavior, not implementation: Assert what the code does, not how
- One assertion per concept: Each test verifies one behavior
- Arrange-Act-Assert: Clear structure in every test
- Factory over fixture: Use FactoryBot for flexible test data
- Avoid
let!: Prefer explicit setup in each test for clarity - Test boundaries: Validate at system edges (user input, API responses), trust internal code
What NOT to Test
- Framework behavior (Rails validations work -- test your validation rules, not
validates) - Private methods (test via public interface)
- Trivial code (getters, delegations)
- Implementation details (which method was called, internal state)
6. Security Principles
Defense in Depth
Apply multiple layers of security so that a breach in one layer is contained:
| Layer | Mechanism |
| Input | Strong parameters, type coercion, allowlists |
| Authentication | has_secure_password, session management, rate limiting |
| Authorization | Pundit policies on every action |
| Output | Automatic HTML escaping, CSP headers |
| Data | Encryption at rest, encrypted credentials |
| Dependencies | Brakeman, bundler-audit in CI |
| Transport | HTTPS everywhere, HSTS headers |
OWASP Top 10 for Rails
| Vulnerability | Rails Protection |
| Injection (SQL, XSS) | Parameterized queries, automatic output escaping |
| Broken Authentication | has_secure_password, secure session config |
| Sensitive Data Exposure | Rails.application.credentials, encrypted attributes |
| XML External Entities | Disabled by default in Nokogiri |
| Broken Access Control | Pundit authorize on every action |
| Security Misconfiguration | config.force_ssl, secure defaults |
| Cross-Site Scripting (XSS) | Auto-escaping, sanitize helper, CSP |
| Insecure Deserialization | JSON over Marshal, strong parameters |
| Components with Vulnerabilities | bundler-audit, Dependabot |
| Insufficient Logging | Rails.logger, structured logging |
Security Checklist
# Every controller action
class PostsController < ApplicationController
before_action :authenticate_user! # Authentication
def update
@post = Post.find(params[:id])
authorize @post # Authorization
@post.update!(post_params) # Strong parameters
end
private
def post_params
params.require(:post).permit(:title, :body) # Allowlist
end
endAutomated Security Scanning
# Static analysis for Rails vulnerabilities
bundle exec brakeman --no-pager
# Known gem vulnerabilities
bundle exec bundler-audit check --update
# Both in CI, fail the build on warnings7. Performance Principles
The Performance Hierarchy
Fix in this order -- each level gives more impact per effort:
- N+1 queries -- Biggest bang for the buck
- Missing indexes -- Free performance
- Unnecessary queries -- Counter caches, eager loading
- Caching -- Fragment, Russian doll, low-level
- Background jobs -- Move slow work out of the request
- Database optimization -- Query tuning, read replicas
- Infrastructure -- Vertical scaling, CDN, edge caching
N+1 Prevention
# BAD: N+1 query
@posts = Post.all
# In view: post.author.name triggers a query per post
# GOOD: Eager loading
@posts = Post.includes(:author)
# GOOD: Strict loading (raises on lazy load)
class Post < ApplicationRecord
self.strict_loading_by_default = true
endUse the Bullet gem to detect N+1 queries automatically in development and test.
Caching Strategy
# Fragment caching (most common)
<% cache @post do %>
<%= render @post %>
<% end %>
# Russian doll caching (nested fragments)
<% cache @post do %>
<% @post.comments.each do |comment| %>
<% cache comment do %>
<%= render comment %>
<% end %>
<% end %>
<% end %>
# Low-level caching (arbitrary data)
Rails.cache.fetch("stats/#{Date.current}", expires_in: 1.hour) do
ExpensiveQuery.call
end
# Counter caches (avoid COUNT queries)
# In migration:
add_column :posts, :comments_count, :integer, default: 0
# In model:
belongs_to :post, counter_cache: trueDatabase Optimization
# Always index foreign keys
add_index :comments, :post_id
# Index columns used in WHERE, ORDER BY, JOIN
add_index :users, :email, unique: true
add_index :orders, [:user_id, :status]
# Use select to limit columns
User.select(:id, :name, :email)
# Use pluck for simple value arrays
User.where(active: true).pluck(:email)
# Use find_each for batch processing
User.find_each(batch_size: 1000) do |user|
# processes in batches, not all at once
end8. Settled Debates
Concerns vs Service Objects
| Concerns | Service Objects | |
| Use for | Simple shared model behavior | Multi-step business logic |
| Examples | SoftDeletable, Searchable, Sluggable | CreateOrder, ProcessRefund |
| Scope | Narrow, one behavior | Complete operation |
| Testing | Tested via including model | Tested in isolation |
| Reuse | Across models | Across controllers/jobs |
Rule of thumb: If the behavior is a property of the model (soft-delete, timestamps, search), use a concern. If it's an operation performed on the model (checkout, transfer, import), use a service.
STI vs Polymorphic Associations
| STI | Polymorphic | |
| Use when | Subclasses share most columns | Types have unique attributes |
| Table | One table, type column | Separate tables per type |
| Good for | Vehicle -> Car, Truck | Comment -> on Post, Photo, Video |
| Avoid when | Many NULL columns per subtype | Types are fundamentally similar |
Rule of thumb: If more than 20% of columns are subtype-specific, don't use STI.
Callbacks vs Explicit Calls
2025 consensus: strongly favor explicit calls over callbacks.
# BAD: Hidden side effects in callbacks
class User < ApplicationRecord
after_create :send_welcome_email
after_create :create_default_settings
after_create :notify_admin
after_create :sync_to_crm
end
# GOOD: Explicit orchestration in service
class RegisterUser
def call(params)
user = User.create!(params)
UserMailer.welcome(user).deliver_later
CreateDefaultSettings.call(user)
NotifyAdmin.call(user)
SyncToCrm.call(user)
user
end
endWhen callbacks are acceptable:
- Simple data normalization:
before_validation :strip_whitespace - Always-true data transformations:
before_save :downcase_email - Database-level consistency:
before_destroy :check_dependencies
Never for:
- Sending emails
- Creating related records with business logic
- Calling external APIs
- Anything contextual (happens sometimes but not always)
Minitest vs RSpec
Both are excellent. The 2025 community is split:
- Minitest: Ships with Rails, lean, fast, closer to plain Ruby
- RSpec: Expressive DSL, larger ecosystem, better for living documentation
Choose based on team preference. Don't switch mid-project.
9. Anti-Patterns to Avoid
The God Model
# BAD: Model does everything
class User < ApplicationRecord
# 500+ lines
# Handles auth, billing, notifications, reporting, admin...
end
# GOOD: Extract to focused collaborators
class User < ApplicationRecord
# 50 lines: validations, associations, simple scopes
end
# + UserRegistrationService, BillingService, NotificationPreferences, etc.The Service Graveyard
# BAD: Service for every trivial operation
class UpdateUserName
def call(user, name)
user.update!(name: name)
end
end
# GOOD: Just use the model
user.update!(name: params[:name])Don't create services for simple CRUD. The bar for extraction is real complexity.
Callback Spaghetti
# BAD: Cascading callbacks across models
class Order < ApplicationRecord
after_create :update_inventory # triggers Inventory callbacks
after_create :charge_payment # triggers Payment callbacks
after_create :send_notifications # triggers Notification callbacks
# Each of those triggers more callbacks...
end
# Result: unpredictable execution order, impossible to test in isolationN+1 Ignorance
# BAD: Looks simple, triggers 101 queries
@posts = Post.limit(100)
@posts.each { |p| p.author.name }
# GOOD: 2 queries total
@posts = Post.includes(:author).limit(100)Premature Abstraction
# BAD: Abstract base class for two services
class BaseService
def initialize(params); end
def validate!; end
def execute; end
def notify; end
def call
validate!
result = execute
notify
result
end
end
# GOOD: Just write the two services independently
# Extract a base class only when you have 5+ services with identical structureOver-Testing
# BAD: Testing Rails framework behavior
it "has a name column" do
expect(User.column_names).to include("name")
end
# BAD: Testing implementation details
it "calls the private method" do
expect(service).to receive(:validate_params).and_call_original
service.call
end
# GOOD: Testing behavior
it "rejects orders with negative totals" do
order = build(:order, total: -1)
expect(order).not_to be_valid
endSources
- The Ruby on Rails Doctrine
- Rails World 2025: A Call for Radical Simplicity
- DHH's Rails World 2025 Keynote: We're All CRUD Monkeys
- Rails Best Practices 2025: Why Boring Works
- Rails Testing Best Practices 2025
- Ruby on Rails 8 Modern Architecture Overview
- Layered Architecture in Ruby on Rails
- Concerns vs Service Objects
- SOLID Principles in Rails
- Rails 8 Solid Trifecta
- OWASP Ruby on Rails Cheat Sheet
- Rails Security Measures Guide
- ActiveRecord Anti-Patterns: Callbacks
- Skinny Controllers, Skinny Models
- The Testing Pyramid for Rails
- ViewComponent Documentation
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

