Model Patterns (37signals)
Rich domain models over service objects. Business logic lives in models, not in separate service classes.
Project knowledge
Tech Stack: Rails 8.2 (edge), UUIDs everywhere, database-backed everything (no Redis) Patterns: Heavy use of concerns, default values via lambdas, Current for context
Commands:
bin/rails generate model Card title:string body:text board:references:uuid
bin/rails generate migration AddColorToCards color:string
bin/rails db:migrate
bin/rails test test/models/
bin/rails console
Rich model vs service object
# BAD -- service object
class CloseCardService
def initialize(card, user)
@card = card
@user = user
end
def call
ActiveRecord::Base.transaction do
@card.create_closure!(user: @user)
@card.track_event("card_closed", user: @user)
end
end
end
# GOOD -- rich model
class Card < ApplicationRecord
include Closeable
def close(user: Current.user)
create_closure!(user: user)
track_event "card_closed", user: user
notify_recipients_later
end
end
# Controller simply calls:
@card.close
Model structure
Order within a model:
class Card < ApplicationRecord
# 1. Concern includes
include Assignable, Closeable, Eventable, Searchable, Watchable
# 2. Associations
belongs_to :account, default: -> { board.account }
belongs_to :board, touch: true
belongs_to :column, touch: true
belongs_to :creator, class_name: "User", default: -> { Current.user }
has_many :comments, dependent: :destroy
has_many :assignments, dependent: :destroy
has_one :closure, dependent: :destroy
# 3. Validations
validates :title, presence: true
validates :status, inclusion: { in: %w[draft published archived] }
# 4. Enums
enum :status, { draft: "draft", published: "published", archived: "archived" }, default: :draft
# 5. Scopes
scope :recent, -> { order(created_at: :desc) }
scope :positioned, -> { order(:position) }
scope :active, -> { open.published.where.missing(:not_now) }
# 6. Delegations
delegate :name, to: :board, prefix: true, allow_nil: true
# 7. Callbacks (sparingly)
after_create_commit :broadcast_creation
# 8. Business logic methods
def publish
update!(status: :published)
track_event "card_published"
end
def move_to_column(new_column)
update!(column: new_column)
track_event "card_moved", particulars: {
from_column_id: column_id_before_last_save,
to_column_id: new_column.id
}
end
private
def broadcast_creation
broadcast_prepend_to board, :cards, target: "cards", partial: "cards/card"
end
end
Association patterns
belongs_to with defaults
belongs_to :account, default: -> { board.account }
belongs_to :creator, class_name: "User", default: -> { Current.user }
belongs_to :board, touch: true # Updates parent's updated_at
has_many / has_one
has_many :comments, dependent: :destroy
has_many :assignees, through: :assignments, source: :assignee
has_one :closure, dependent: :destroy
Polymorphic
has_many :attachments, as: :attachable, dependent: :destroy
has_many :events, as: :eventable, dependent: :destroy
belongs_to :notifiable, polymorphic: true
Counter caches
belongs_to :card, counter_cache: :comments_count
belongs_to :board, counter_cache: :cards_count
Scope patterns
# Basic ordering
scope :recent, -> { order(created_at: :desc) }
scope :positioned, -> { order(:position) }
# With arguments
scope :by_creator, ->(user) { where(creator: user) }
scope :created_after, ->(date) { where("created_at > ?", date) }
# Joins and where.missing (key pattern for state records)
scope :assigned_to, ->(users) { joins(:assignments).where(assignments: { assignee: users }).distinct }
scope :open, -> { where.missing(:closure) }
scope :unassigned, -> { where.missing(:assignments) }
# Complex composed scopes
scope :entropic, -> {
open.published.where.missing(:not_now).where("updated_at < ?", 30.days.ago)
}
Validation patterns
validates :title, presence: true
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :email_address, uniqueness: { case_sensitive: false }
validates :user_id, uniqueness: { scope: :card_id } # Join tables
validates :card, uniqueness: true # has_one state records
validates :body, presence: true, if: :published? # Conditional
Callbacks and enums
# Callbacks: use sparingly, prefer _commit for external effects
after_create_commit :broadcast_creation
before_validation :set_default_status, on: :create
after_create_commit :notify_recipients_later # Uses _later convention
# String enums (preferred for DB readability)
enum :status, {
draft: "draft", published: "published", archived: "archived"
}, default: :draft, prefix: true
Business logic methods
Action methods (verbs)
def close(user: Current.user)
create_closure!(user: user)
track_event "card_closed", user: user
notify_watchers_later
end
def assign(user)
assignments.create!(user: user) unless assigned_to?(user)
track_event "card_assigned", particulars: { assignee_id: user.id }
end
Query methods (predicates)
def closed?
closure.present?
end
def assigned_to?(user)
assignees.include?(user)
end
def can_be_edited_by?(user)
user.can_administer_card?(self) || creator == user
end
Computed attributes
def closed_at
closure&.created_at
end
def closed_by
closure&.user
end
_later / _now convention
# Async version (queues a job)
def notify_recipients_later
NotifyRecipientsJob.perform_later(self)
end
# Sync version (immediate execution)
def notify_recipients_now
recipients.each do |recipient|
Notification.create!(recipient: recipient, notifiable: self)
end
end
# Default to sync
def notify_recipients
notify_recipients_now
end
# Call _later from callbacks
after_create_commit :notify_recipients_later
Using Current for context
class Current < ActiveSupport::CurrentAttributes
attribute :session, :user, :identity, :account
end
class Card < ApplicationRecord
belongs_to :creator, class_name: "User", default: -> { Current.user }
belongs_to :account, default: -> { Current.account }
def close(user: Current.user)
create_closure!(user: user)
end
end
See references/model-examples.md for complete model examples (join tables, form objects, POROs, migrations, tests).
Boundaries
- Always: Put business logic in models, use concerns for organization, use bang methods (
create!,update!), leverage associations and scopes, use Current for context, default values via lambdas - Ask first: Before creating service objects, before adding complex callbacks, before using inheritance (prefer composition via concerns)
- Never: Create anemic models (data without behavior), put business logic in controllers, skip validations, create models without tests