agentsclimarketplace

Writing models

Skill chrisrackstallion/pitchd-rails-cursor-plugin/skills/writing-models

Write Rails models following DHH/37signals conventions — rich domain models, concerns for horizontal behaviour, state-as-records, scopes, database constraints, and vanilla Rails patterns. Use when creating new models, adding model methods, extracting concerns, refactoring model logic, or when the user mentions models, concerns, scopes, callbacks, or domain logic.From its SKILL.md

Install
npx -y skills add chrisrackstallion/pitchd-rails-cursor-plugin --skill writing-models

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

7.9 KB, as published. Nobody here has run it

Writing Rails Models

<objective> Write models that are rich domain objects — the heart of the application, not anaemic data bags. Models own business logic, express domain language, and keep controllers thin. Follow DHH/37signals conventions: concerns for horizontal behaviour, state tracked as records, database-backed everything, and clarity over cleverness. </objective>

Process

1. Determine What You're Building

TaskAction
New modelRead references/patterns.md, scaffold the model
New concernRead references/patterns.md § Concerns
Scopes / queriesRead references/patterns.md § Scopes
State trackingRead references/patterns.md § State as Records — default: enums → state records → history → state machine gem only when complexity demands it
RefactoringRead references/patterns.md, identify which pattern applies
Code reviewRead all references, review against conventions

2. Model Structure

Organise every model in this order:

class Article < ApplicationRecord
  # 1. Concerns (capabilities this model has)
  include Publishable
  include Taggable
  include Searchable

  # 2. Constants
  VISIBILITIES = { public: "public", private: "private", unlisted: "unlisted" }.freeze

  # 3. Associations (belongs_to first, then has_many, then has_one)
  belongs_to :author, class_name: "User", default: -> { Current.user }
  has_many :comments, dependent: :destroy

  # 4. Delegated types / enums / store accessors
  enum :visibility, VISIBILITIES, default: :public, prefix: true

  # 5. Normalizes
  normalizes :title, with: ->(t) { t.strip }

  # 6. Validations (minimal — database constraints do the heavy lifting)
  validates :title, presence: true

  # 7. Scopes
  scope :chronologically, -> { order(created_at: :asc) }
  scope :preloaded, -> { includes(:author, :tags) }

  # 8. Callbacks (sparingly)
  after_create_commit :notify_subscribers_later

  # 9. Class methods
  def self.search(query)
    where("title ILIKE ?", "%#{sanitize_sql_like(query)}%")
  end

  # 10. Public instance methods — domain verbs
  # (publishing is handled by the Publishable concern via state record)

  private
    def notify_subscribers_later
      NotifySubscribersJob.perform_later(id)
    end
end

The Publishable concern (from references/patterns.md) provides publish, unpublish, published?, and scopes like published / unpublished via a Publication state record. Enums are used for a different dimension (visibility) that doesn't need audit trails. Never mix both patterns for the same concept.

prefix: true on the enum is required here because bare public / private values would generate Article.public / Article.private class methods that shadow Ruby's Module#public / Module#private visibility modifiers. With the prefix you get article.visibility_public? and Article.visibility_public instead. Values that don't collide with existing methods (draft, archived) don't need a prefix.

3. Decision Framework

Before writing code, ask these questions:

"Does this logic belong on the model?"

  • Operates on a single model instance → model method
  • Crosses model boundaries → controller orchestration or a PORO namespaced under the primary model (e.g. Article::Publisher)
  • Hits external systems → job for async; PORO or model method for sync
  • Shared across unrelated models → concern
  • Pure presentation → helper or component

"Is this a new concept or a capability?"

  • New concept (own table, own identity) → new model
  • Capability added to existing model → concern
  • Derived/computed state → method on the model

"Boolean column or state record?"

  • Need to know when and who → state record
  • Simple flag with no history → boolean is acceptable
  • Will be queried with joins → state record

"Enum, state record, or state machine gem?"

  • Small lifecycle on one column, no audit needs → enum
  • Binary or few modes with who/when or joins → state record + verbs on the parent
  • Full history of every transition → history table (or event log) when the product requires it
  • Many states, transition matrix, heavy guards, or external workflow API → consider a state machine gem only after the simpler shapes fail — see references/patterns.md § State as Records

4. Anti-Patterns

Anti-PatternInstead
Service wrapping a single update!Model method
app/services/ single-method service objectsModel method or PORO under model namespace
Callback chains for business logicExplicit method calls
before_save touching other modelsController orchestration or explicit call
Fat controller with inline queriesNamed scopes
Generic set_X / update_X methodsDomain verbs (publish, close)
Boolean columns for auditable stateState records with timestamps
Model validations for data integrityDatabase constraints
rescue => e in model methodsLet exceptions propagate
default_scopeNamed scopes — default_scope contaminates all queries
ActiveRecord::Base subclass with no tableActiveModel::Model instead
Workflow-specific validations via on: contexts or mode flags (if: :signup_step_two?)Form objects — one per workflow (references/patterns.md § Validations). Attribute-state guards (if: :title_changed?) are fine
State machine gem for two-state lifecyclesState record + domain verbs; enum if a column is enough
Callback-driven transitions (after_transition doing business logic)Explicit verbs + transactions; after_commit only for async side effects
Scattered transition_to! / case state across the appCentralize in model methods or one object next to the model; gem only if the matrix warrants it

5. Naming Conventions

ThingConventionExamples
ConcernsAdjective describing capabilityPublishable, Closeable, Watchable
ScopesAdverb or business termchronologically, active, preloaded
State recordsNoun for the stateClosure, Publication, Archival
VerbsDomain action (non-bang)publish, close, archive, gild
PredicatesState querypublished?, closed?, golden?
POROsNamespaced under parentArticle::Summary, User::Filtering

6. Verification

Before finishing, verify:

  • No logic that belongs in a service sitting on the model (multi-model, external calls)
  • No logic that belongs on the model sitting in a service (single-model operations)
  • Concerns are self-contained (associations + scopes + methods together)
  • Every concern method earns its place — each is part of the capability all includers get; methods only one includer uses, or that aren't about the capability, live on that model instead
  • Database constraints back up any model validations
  • Scopes handle all domain queries — no raw where chains in controllers
  • Callbacks are limited to async dispatch and derived data
  • Method names use domain language, not generic CRUD terms
  • Domain verbs use the non-bang form; bangs live on the internal AR calls (create!, update!) so failures raise and propagate
  • State modeled with the simplest shape that fits: enum → state record → history/gem only when justified (references/patterns.md § State as Records)

References

For detailed patterns and examples, see references/patterns.md.

Keep looking

Skills are one crate of 627,801. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.