Single-page version 1 / 21

Senior Track · Spot the Tax

Working code that will cost you.

Five Rails snippets, walked through one stage at a time. Look at each one and try to spot the tax before clicking through.

Press to start. Use / to navigate.

Card 1 of 5 · Spot the tax

The validation that lies under load

What will this cost you in six months?

class User < ApplicationRecord
  validates :email, presence: true, uniqueness: true
end

# In the controller:
def create
  @user = User.new(user_params)
  if @user.save
    redirect_to @user
  else
    render :new
  end
end

Card 1 of 5 · The problem

The validation that lies under load

class User < ApplicationRecord
  validates :email, presence: true, uniqueness: true
end

# In the controller:
def create
  @user = User.new(user_params)
  if @user.save
    redirect_to @user
  else
    render :new
  end
end

Two simultaneous signups both pass valid? and both insert. The validation runs in Ruby, between the read and the write, with no lock. The database ends up with two users sharing the same email.

Card 1 of 5 · The solution

The validation that lies under load

class User < ApplicationRecord
  validates :email, presence: true, uniqueness: true
end

# In the controller:
def create
  @user = User.new(user_params)
  if @user.save
    redirect_to @user
  else
    render :new
  end
end

Put the uniqueness rule in the database itself. Only the database can guarantee that no two rows have the same value at the same time.

  • The rule holds under any amount of concurrent traffic
  • The database raises a specific error you can catch
  • The validation can stay for the friendly UI message
# In a migration:
add_index :users, :email, unique: true

Card 1 of 5 · The principle at play

The validation that lies under load

class User < ApplicationRecord
  validates :email, presence: true, uniqueness: true
end

# In the controller:
def create
  @user = User.new(user_params)
  if @user.save
    redirect_to @user
  else
    render :new
  end
end

Database constraints

When two web requests arrive at the same instant, your Rails server runs them in parallel on different processes. Neither one sees the other's in-flight work. The validation runs in Ruby, between "is this email free?" and "INSERT." Both requests can pass a millisecond apart, before either has actually inserted, and both then succeed.

The database is the only part of the stack that sees every write at once. A unique index tells it "reject any insert that duplicates this column." The first insert wins. The second one fails immediately, before the row exists.

That's why uniqueness has to live there. It isn't that Ruby is wrong. It's that no Ruby process has visibility over what other processes are doing in this moment. Only the database does.

Card 2 of 5 · Spot the tax

The callback that takes the database hostage

What will this cost you in six months?

class User < ApplicationRecord
  before_save :sync_to_mailchimp

  private

  def sync_to_mailchimp
    Mailchimp::Subscribers.upsert(
      email: email,
      merge_fields: { name: name }
    )
  end
end

Card 2 of 5 · The problem

The callback that takes the database hostage

class User < ApplicationRecord
  before_save :sync_to_mailchimp

  private

  def sync_to_mailchimp
    Mailchimp::Subscribers.upsert(
      email: email,
      merge_fields: { name: name }
    )
  end
end

Every save now waits for Mailchimp to respond. The save runs inside a database transaction, which holds open until the API call returns. If Mailchimp is slow, saves are slow. If Mailchimp is down, saves fail. If the transaction rolls back after the API call already succeeded, the user exists in Mailchimp but not in your database.

Card 2 of 5 · The solution

The callback that takes the database hostage

class User < ApplicationRecord
  before_save :sync_to_mailchimp

  private

  def sync_to_mailchimp
    Mailchimp::Subscribers.upsert(
      email: email,
      merge_fields: { name: name }
    )
  end
end

Move the external call out of the lifecycle. After the controller saves, enqueue a job that handles the sync.

  • A Mailchimp outage doesn't break user signups
  • The job retries on failure, the save doesn't
  • Database transactions stay short
class UsersController < ApplicationController
  def create
    @user = User.new(user_params)
    if @user.save
      MailchimpSyncJob.perform_later(@user.id)
      redirect_to @user
    else
      render :new
    end
  end
end

Card 2 of 5 · The principle at play

The callback that takes the database hostage

class User < ApplicationRecord
  before_save :sync_to_mailchimp

  private

  def sync_to_mailchimp
    Mailchimp::Subscribers.upsert(
      email: email,
      merge_fields: { name: name }
    )
  end
end

Transaction scope

Rails wraps every save in a database transaction — a deal with the database to do a series of writes together, with a guarantee that they all succeed or none of them do. While the transaction is open, the database holds locks. Other requests trying to touch those rows wait.

If your before_save calls Mailchimp and the API takes two seconds, your transaction holds locks for two seconds. Other requests stack up behind it. And if anything later in the transaction fails, the user save rolls back — but the Mailchimp call already went out. There is no rollback for HTTP.

That's why external calls live after the transaction commits. The "all or nothing" guarantee only covers database writes. Putting an HTTP call inside the transaction creates a state your database can't undo.

Card 3 of 5 · Spot the tax

The cascade that runs in Ruby

What will this cost you in six months?

class User < ApplicationRecord
  has_many :events, dependent: :destroy
  has_many :messages, dependent: :destroy
  has_many :reactions, dependent: :destroy
end

# In the controller:
def destroy
  current_user.destroy
  redirect_to root_path
end

Card 3 of 5 · The problem

The cascade that runs in Ruby

class User < ApplicationRecord
  has_many :events, dependent: :destroy
  has_many :messages, dependent: :destroy
  has_many :reactions, dependent: :destroy
end

# In the controller:
def destroy
  current_user.destroy
  redirect_to root_path
end

Deleting a user with 50,000 events instantiates 50,000 Ruby objects, fires 50,000 callbacks, and runs 50,000 DELETE statements. The delete that should take 50ms takes minutes, sometimes times out partway through, and leaves the data in a half-deleted state.

Card 3 of 5 · The solution

The cascade that runs in Ruby

class User < ApplicationRecord
  has_many :events, dependent: :destroy
  has_many :messages, dependent: :destroy
  has_many :reactions, dependent: :destroy
end

# In the controller:
def destroy
  current_user.destroy
  redirect_to root_path
end

Pick the right cascade for the work. :delete_all deletes in SQL without instantiating Ruby objects. :destroy is for cases where you actually need callbacks on the children.

  • Deletes happen at SQL speed
  • Memory stays bounded regardless of how many associated rows exist
  • For real :destroy cases, the work can move to a job that you can monitor and retry
class User < ApplicationRecord
  has_many :events, dependent: :delete_all
  has_many :messages, dependent: :delete_all
  has_many :reactions, dependent: :delete_all
end

Card 3 of 5 · The principle at play

The cascade that runs in Ruby

class User < ApplicationRecord
  has_many :events, dependent: :destroy
  has_many :messages, dependent: :destroy
  has_many :reactions, dependent: :destroy
end

# In the controller:
def destroy
  current_user.destroy
  redirect_to root_path
end

Bulk operations in SQL

SQL and Ruby work very differently. SQL runs inside the database, in one round trip, optimized by the query planner. Ruby runs in your application, one object at a time, with all the overhead of object creation and callback chains.

dependent: :destroy runs in Ruby. Rails loads every associated record, instantiates each one, fires its destroy callbacks, then runs DELETE for that single row. Fifty thousand records means fifty thousand objects in memory and fifty thousand DELETE statements. dependent: :delete_all runs in SQL — one statement, done in milliseconds.

They aren't two ways to write the same operation. They're two different operations with the same name. Pick :destroy only when you actually need callbacks to fire on the children. Most of the time, you don't.

Card 4 of 5 · Spot the tax

The schema you can't query

What will this cost you in six months?

class Order < ApplicationRecord
  serialize :metadata, JSON
end

# Writing:
order.metadata = { plan: "pro", referrer: "google", utm_campaign: "spring" }
order.save

# Reading later, in a report:
Order.where("created_at > ?", 90.days.ago).select do |o|
  o.metadata["plan"] == "pro"
end

Card 4 of 5 · The problem

The schema you can't query

class Order < ApplicationRecord
  serialize :metadata, JSON
end

# Writing:
order.metadata = { plan: "pro", referrer: "google", utm_campaign: "spring" }
order.save

# Reading later, in a report:
Order.where("created_at > ?", 90.days.ago).select do |o|
  o.metadata["plan"] == "pro"
end

The metadata column is text, holding JSON. Writing dumps the hash. Reading parses it. Querying inside the JSON requires loading every row, parsing in Ruby, and filtering. The database can't index inside a serialized text blob.

Card 4 of 5 · The solution

The schema you can't query

class Order < ApplicationRecord
  serialize :metadata, JSON
end

# Writing:
order.metadata = { plan: "pro", referrer: "google", utm_campaign: "spring" }
order.save

# Reading later, in a report:
Order.where("created_at > ?", 90.days.ago).select do |o|
  o.metadata["plan"] == "pro"
end

If a field is queryable, give it a real column with an index. If the data is genuinely opaque, use Postgres jsonb instead of a serialized text column.

  • Queries inside the data become possible
  • The database uses indexes instead of full scans
  • Reads and writes don't pay a parse/dump tax
# Option 1: promote the queryable field to a column
add_column :orders, :plan, :string
add_index :orders, :plan

# Option 2: switch to jsonb for genuinely opaque data
change_column :orders, :metadata, :jsonb, using: "metadata::jsonb"
add_index :orders, :metadata, using: :gin

Card 4 of 5 · The principle at play

The schema you can't query

class Order < ApplicationRecord
  serialize :metadata, JSON
end

# Writing:
order.metadata = { plan: "pro", referrer: "google", utm_campaign: "spring" }
order.save

# Reading later, in a report:
Order.where("created_at > ?", 90.days.ago).select do |o|
  o.metadata["plan"] == "pro"
end

Indexable storage

Databases are fast at queries because they build indexes. An index is a separate structure that maps "this value lives at these rows." A query for plan = 'pro' can use the index to jump directly to the matching rows instead of scanning every row.

Indexes work on column values. They don't look inside text fields. When you store JSON in a text column, the database sees one big string. It can't index metadata.plan. To filter on that field, the database has to load every row, your app parses every JSON string, and Ruby filters in memory.

Postgres jsonb is different. It stores JSON in a binary format the database understands and lets you build indexes on paths inside it. The database stays in charge of the work. JSON-as-text is fine when you genuinely never query inside the data, but the moment you do, the storage choice decides whether the database can help you.

Card 5 of 5 · Spot the tax

The find that trusts the URL

What will this cost you in six months?

class InvoicesController < ApplicationController
  before_action :authenticate_user!

  def show
    @invoice = Invoice.find(params[:id])
  end
end

Card 5 of 5 · The problem

The find that trusts the URL

class InvoicesController < ApplicationController
  before_action :authenticate_user!

  def show
    @invoice = Invoice.find(params[:id])
  end
end

Anyone logged in can view any invoice by changing the URL from /invoices/123 to /invoices/124. Authentication checked who they are. Authorization (whether they can see this invoice) was never checked.

Card 5 of 5 · The solution

The find that trusts the URL

class InvoicesController < ApplicationController
  before_action :authenticate_user!

  def show
    @invoice = Invoice.find(params[:id])
  end
end

Make the query itself enforce ownership. If the invoice doesn't belong to the user, the lookup raises RecordNotFound.

  • The unsafe version stops being writable — you have to scope the query
  • One enforcement point instead of remembering to authorize after every find
  • A 404 reveals less information than a 403
def show
  @invoice = current_user.invoices.find(params[:id])
end

Card 5 of 5 · The principle at play

The find that trusts the URL

class InvoicesController < ApplicationController
  before_action :authenticate_user!

  def show
    @invoice = Invoice.find(params[:id])
  end
end

Scoped queries

The web works on URLs that anyone can change. Your server gets a request for /invoices/123 and has to decide what to return. Two questions go into that decision, and they are separate. Authentication asks "who is this user?" — Devise and similar tools answer it. Authorization asks "what is this user allowed to see?" — different question, different code.

Invoice.find(params[:id]) doesn't ask the second question. By writing that line, you've decided "any logged-in user can see any invoice." You can fix it by adding an authorization check after the find, but that relies on every developer remembering it on every action.

current_user.invoices.find(params[:id]) builds the query against the user's own records. If the invoice doesn't belong to the user, nothing matches and find raises RecordNotFound. There is no path through the code where authorization can be skipped, because authorization is part of the query. That's the rule: build the safe path so the unsafe one becomes harder to write than the safe one.