State Records (37signals)
State as records, not booleans. Instead of closed: boolean, create a Closure record.
Project knowledge
Tech Stack: Rails 8.2 (edge), UUIDs everywhere, ActiveRecord associations Pattern: One state model per boolean you'd normally add Naming: Noun forms (Closure, Publication, Goldness, NotNow, Archival)
Commands:
bin/rails generate model Closure card:references:uuid user:references:uuid account:references:uuid
bin/rails db:migrate
bin/rails console # Test: Card.open.count
bin/rails test test/models/
Why state records over booleans
Boolean columns give you:
- Current state (open/closed)
State records give you:
- Current state (
closure.present?) - When it changed (
closure.created_at) - Who changed it (
closure.user) - Why it changed (
closure.reason) - Change history (via events)
The pattern
Boolean approach (avoid for business state):
# BAD
class Card < ApplicationRecord
def close
update!(closed: true, closed_at: Time.current)
end
scope :open, -> { where(closed: false) }
end
State record approach:
# GOOD
class Closure < ApplicationRecord
# touch: true ensures the parent's updated_at changes when state changes,
# which drives cache invalidation (Russian doll caching, ETags, etc.)
belongs_to :card, touch: true
belongs_to :user, optional: true
belongs_to :account, default: -> { card.account }
validates :card, uniqueness: true
end
class Card < ApplicationRecord
has_one :closure, dependent: :destroy
def close(user: Current.user)
create_closure!(user: user)
end
def reopen
closure&.destroy!
end
def closed?
closure.present?
end
scope :open, -> { where.missing(:closure) }
scope :closed, -> { joins(:closure) }
end
State record model template
Every state record model follows this structure:
class Closure < ApplicationRecord
belongs_to :account, default: -> { card.account }
belongs_to :card, touch: true
belongs_to :user, optional: true
validates :card, uniqueness: true
after_create_commit :notify_watchers
after_destroy_commit :notify_watchers
private
def notify_watchers
card.notify_watchers_later
end
end
State concern template
Every state concern follows this structure:
module Card::Closeable
extend ActiveSupport::Concern
included do
has_one :closure, dependent: :destroy
scope :open, -> { where.missing(:closure) }
scope :closed, -> { joins(:closure) }
end
def close(user: Current.user)
create_closure!(user: user)
track_event "card_closed", user: user
end
def reopen
closure&.destroy!
track_event "card_reopened"
end
def closed?
closure.present?
end
def open?
!closed?
end
def closed_at
closure&.created_at
end
def closed_by
closure&.user
end
end
State record with metadata
When state needs additional data (secure tokens, descriptions):
class Board::Publication < ApplicationRecord
belongs_to :account, default: -> { board.account }
belongs_to :board, touch: true
has_secure_token :key
validates :board, uniqueness: true
def public_url
Rails.application.routes.url_helpers.public_board_url(key)
end
end
module Board::Publishable
extend ActiveSupport::Concern
included do
has_one :publication, dependent: :destroy
scope :published, -> { joins(:publication) }
scope :private, -> { where.missing(:publication) }
end
def publish(description: nil)
create_publication!(description: description)
track_event "board_published"
end
def unpublish
publication&.destroy!
track_event "board_unpublished"
end
def published?
publication.present?
end
def public_url
publication&.public_url
end
end
Query patterns with state records
# Finding by state: positive uses joins, negative uses where.missing
Card.open # where.missing(:closure)
Card.closed # joins(:closure)
Board.published # joins(:publication)
Card.golden # joins(:goldness)
# Complex combinations
scope :actionable, -> {
where.missing(:closure).where.missing(:not_now).where.missing(:archival)
}
# Sorting by state
scope :with_golden_first, -> {
left_outer_joins(:goldness)
.select("cards.*", "card_goldnesses.created_at as golden_at")
.order(Arel.sql("golden_at IS NULL, golden_at DESC"))
}
# Filtering by actor
scope :closed_by, ->(user) { joins(:closure).where(closures: { user: user }) }
Controller patterns
State changes map to singular resources with create/destroy:
# config/routes.rb
resources :cards do
resource :closure, only: [:create, :destroy], module: :cards
resource :goldness, only: [:create, :destroy], module: :cards
resource :not_now, only: [:create, :destroy], module: :cards
end
# app/controllers/cards/closures_controller.rb
class Cards::ClosuresController < ApplicationController
include CardScoped
def create
@card.close(user: Current.user)
render_card_replacement
end
def destroy
@card.reopen
render_card_replacement
end
end
View patterns
<%# Toggle button %>
<%= button_to card_goldness_path(card),
method: card.golden? ? :delete : :post,
data: { turbo_frame: dom_id(card) } do %>
<%= card.golden? ? "Ungild" : "Gild" %>
<% end %>
<%# State badge %>
<% if card.closed? %>
<span class="badge badge--closed">
Closed <%= time_ago_in_words(card.closed_at) %> ago
<% if card.closed_by %>by <%= card.closed_by.name %><% end %>
</span>
<% end %>
When to use state records vs booleans
Use state records when:
- You need to know when state changed
- You need to know who changed it
- You might store metadata (reason, notes)
- State changes are important business events
- You need queries like "recently closed" or "closed by X"
Use booleans when:
- State is purely technical (cached, processed)
- Timestamp/actor don't matter
- Performance is critical (millions of rows, frequent updates)
- State changes are not business events
Quick reference:
- State records: closed, published, archived, suspended, verified, pinned, golden, postponed
- Booleans: admin, cached, processed, visible
See references/state-record-examples.md for complete examples of each state type.
Migration from boolean to state record
- Create state record model + migration
- Backfill existing data
- Update model code to use concern
- Remove boolean column (after verification)
Boundaries
- Always: Create state record for business-meaningful states, track who and when, use
where.missingfor negative scopes, add unique index on parent_id, touch parent record, write tests for state transitions - Ask first: Before using boolean columns for business state, before adding complex metadata (might need separate model)
- Never: Use booleans for important business state, skip who/when tracking, create multiple state records per parent (use
has_onewith unique index), skip event tracking for state changes