| name | model-patterns |
| description | Builds rich domain models with business logic, concerns, and proper associations following the fat-models-over-service-objects philosophy. Use when creating models, adding validations, scopes, callbacks, business logic methods, or associations. WHEN NOT: Controller/routing work (use crud-patterns), concern extraction (use concern-patterns), state record design (use state-records). |
| license | MIT |
| compatibility | Ruby 3.3+, Rails 8.0+ |
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
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
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
@card.close
Model structure
Order within a model:
class Card < ApplicationRecord
include Assignable, Closeable, Eventable, Searchable, Watchable
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
validates :title, presence: true
validates :status, inclusion: { in: %w[draft published archived] }
enum :status, { draft: "draft", published: "published", archived: "archived" }, default: :draft
scope , -> { order( ) }
scope , -> { order() }
scope , -> { open.published.where.missing() }
delegate , , ,
after_create_commit
update!( )
track_event
()
update!( new_column)
track_event , {
column_id_before_last_save,
new_column.id
}
broadcast_prepend_to board, , ,
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
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
scope :recent, -> { order(created_at: :desc) }
scope :positioned, -> { order(:position) }
scope :by_creator, ->(user) { where(creator: user) }
scope :created_after, ->(date) { where("created_at > ?", date) }
scope :assigned_to, ->(users) { joins(:assignments).where(assignments: { assignee: users }).distinct }
scope :open, -> { where.missing(:closure) }
scope :unassigned, -> { where.missing(:assignments) }
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 }
validates :card, uniqueness: true
validates :body, presence: true, if: :published?
Callbacks and enums
after_create_commit :broadcast_creation
before_validation :set_default_status, on: :create
after_create_commit :notify_recipients_later
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
def notify_recipients_later
NotifyRecipientsJob.perform_later(self)
end
def notify_recipients_now
recipients.each do |recipient|
Notification.create!(recipient: recipient, notifiable: self)
end
end
def notify_recipients
notify_recipients_now
end
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