Same five snippets as the main page. This time each card walks you through the lesson in three steps. Look at the code, take a guess, then reveal the hint, the solution, and the principle.
How to use this page. For each card, read the code and ask yourself "what's wrong with this in six months?" Click Reveal the hint to see whether you spotted the problem. Then walk through the solution and the underlying principle.
Card 1
The validation that lies under load
What will this cost you in six months?
class User < ApplicationRecord validates :email, presence: true, uniqueness: trueend# In the controller:def create @user = User.new(user_params) if @user.save redirect_to @user else render :new endend
The problem : 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.
The solution : 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
The principle at play :
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
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 } ) endend
The problem : 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.
The solution : 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 endend
The principle at play :
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
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: :destroyend# In the controller:def destroy current_user.destroy redirect_to root_pathend
The problem : 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.
The solution : 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_allend
The principle at play :
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
The schema you can't query
What will this cost you in six months?
class Order < ApplicationRecord serialize :metadata, JSONend# 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 problem : 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.
The solution : 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 columnadd_column :orders, :plan, :stringadd_index :orders, :plan# Option 2: switch to jsonb for genuinely opaque datachange_column :orders, :metadata, :jsonb, using: "metadata::jsonb"add_index :orders, :metadata, using: :gin
The principle at play :
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. That's where the rule comes from: 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
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]) endend
The problem : 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.
The solution : 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
The principle at play :
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.
If the stepped flow worked for you
Tell me. If it lands, the same shape can roll across the SOLID, senior, and DDD cards from the main page.