Coding guidelines
What holds in any language. What holds in Ruby. What holds in Rails.
Encrypt personal data
Don’t store your customers’ phone, email, last name, street in plain text.
Code optimistically
Never write a method nobody calls. Never rescue an error nobody has incurred.
Unchained melody
Two calls on the same object in one expression are the tell: the second is unneeded.
Say it short
Between two versions carrying the same meaning, always pick the shorter name.
No sleights of hand
claimed_by, matching — methods named like a query must not have any side effect.
Shy away from public
Every method that can be private must be private.
Document what’s public
Add a single-line comment above each class/constant/method that is not private.
Communicate through git
Small commits with long descriptions (why the code was shipped) and linear history.
Sharpen your tests
Full coverage and not one line more. Never test code you don’t own.
Write good English
Don’t Titleize. It’s ZIP not Zip. It’s license not licence. Respect the apostrophes.
Prefer many to long files
At most 100 lines in a file. At most 100 characters in a line. Comments count.
Stay object-oriented
You don’t need Service.new.call(params). Any method call is a code smell.
Resist metaprogramming
Don’t interpolate method names at runtime. Don’t send. Don’t break the rules.
No static typing
Ruby does not care what class an object belongs to. Embrace the freedom. 🦆
Take it small
def foo(bar) opening with baz = bar.baz should have been foo(bar.baz).
Don’t splat everywhere
Taking **attributes only to hand them to a method makes params hard to follow.
Don’t over-accessorize
No need for attr_reader :foo if @foo can be used instead.
Return first or never
A method can only return in its first line of code before any work starts.
Limit the parentheses
Omit on a call’s arguments; keep the inner ones where parsing needs them.
Make it readable
Enhance RuboCop for code that is easy to grep and clean to diff:
- Single quotes are the rule; double quotes only for interpolation and escape [1] [2]
- Trailing comma on the last line of multiline literals (closing brace on its line) [3] [4]
whenandelsesit one level in fromcaseandend[5]privatesits withclass; private methods are indented like public ones[6]
train.rb
# A train the departure board is about to announce.
class Train
# Under two minutes the board stays quiet: nobody can act on it.
SILENT_DELAY = 2
# Past a quarter of an hour the board owes a number, not just a word.
VAGUE_DELAY = 15
def initialize(destination:, due:, delay: 0, canceled: false)
@destination = destination
@due = due
@delay = delay
@canceled = canceled
end
# The row the board shows: a heading over each field.
def announcement
{
'Where this train is going' => @destination,
'When it was meant to leave' => @due.strftime('%H:%M'),
'What the board should say' => notice,
}
end
private
def notice
return 'Canceled' if @canceled
case @delay
when ..SILENT_DELAY then 'On time'
when ..VAGUE_DELAY then 'Delayed'
else 'Delayed #{@delay} min'
end
end
endFollow the Rails Way
Extend with Concern, respect strong params, apply convention over configuration.
Embrace REST: endpoints are CRUD on resources. Add custom resources, never actions.
Live on the edge
Build off the main branch of rails/rails. Scrap any ~> from your Gemfile.
Order gems alphabetically and document inline why the app would break without them.
Cache from the start
Rails.cache wherever the same rows would be read or the same markup rendered.
Run tests with config.cache_store = :memory_store to verify caching works.
Declare explicit locals
Don’t pass instance variables to partials—use strict <%# locals: ... %> instead.
Assume Turbo is enabled
Redirect out of the app with allow_other_host: true; after a non-GET with status: :see_other; break out of a frame with data: { turbo_frame: '_top' }.
Trim database queries
Eager-load associations. Check if records exist and loop over them in a single query.
Avoid the cost of User.all if all you display is User.select(:name, :email).
Migrations go both ways
Ensure every migration you write is reversible.
Use change before up/down. Use up_only before reversible { |dir| dir.up }.
Use Active Record types
Clarify a decimal column price stores money with attribute :price, :amount.
All you need is to register a new type: Amount < ActiveRecord::Type::Decimal.
Query with encryption
Invoke encrypt on any sensitive data. Add deterministic: { fixed: false } to columns that must be queried or kept unique such as: User.find_by(email:).
Harness PostgreSQL
PostgreSQL offers native types and extensions that play well with Rails:
type: :citextto store case-insensitive strings such as emailsarray: trueto store an array in a single columncreate_enumto declare enums backed by names, not integers
config/routes.rb
Rails.application.routes.draw do
# A train is canceled by filing its cancellation, never by a verb of its own.
resources :trains do
resource :cancellation, controller: 'trains/cancellations', only: %i[ create destroy ]
end
end
app/models/train.rb
# A train the departure board is about to announce.
class Train < ApplicationRecord
# What a train can be doing, in the order it does it.
STATUSES = %i[ scheduled running arrived ].freeze
belongs_to :route
has_many :stops, dependent: :destroy
has_one :cancellation, dependent: :destroy
enum :status, STATUSES.index_by(&:itself), default: STATUSES.first, validate: true
validates :platform, presence: true, length: { maximum: 4 }
validates :delay, numericality: { greater_than_or_equal_to: 0 }
end
app/controllers/trains/cancellations_controller.rb
# A guard takes a train off the board by filing its cancellation.
class Trains::CancellationsController < ApplicationController
before_action :set_train
# Cancels the train and sends the guard back to the board.
def create
@train.create_cancellation! cancellation_params
redirect_to trains_path, status: :see_other
end
private
def set_train
@train = Train.find params.expect(:train_id)
end
def cancellation_params
params.expect cancellation: [ :reason ]
end
end
app/views/trains/index.html.erb
<h1>Departures</h1>
<%= cache @trains do %>
<table class='table'>
<tbody>
<%= render partial: 'train', collection: @trains %>
</tbody>
</table>
<% end %>
app/views/trains/_train.html.erb
<%# locals: (train:) -%>
<tr>
<td><%= train.route.name %></td>
<td><%= train.due.strftime '%H:%M' %></td>
<td><%= train.stops.size %></td>
<td><%= train.status %></td>
</tr>
db/migrate/20260823090000_create_trains.rb
class CreateTrains < ActiveRecord::Migration[8.2]
def change
create_enum :train_status, Train::STATUSES
create_table :trains do |t|
t.enum :status, enum_type: :train_status, default: Train::STATUSES.first, null: false
t.string :platform, limit: 4, null: false
t.integer :delay, default: 0, null: false
t.references :route, null: false, foreign_key: true
t.text :notices, array: true, default: [], null: false
t.datetime :due, null: false
t.timestamps
t.check_constraint 'delay >= 0', name: 'trains_delay_not_negative'
end
end
end