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
:destroycases, 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.