Agent Skills: Active Model Expert

Build plain Ruby objects that integrate with Rails forms, validations, serialization, and Action Pack — without a database. Use when building form objects, search forms, API wrappers, configuration objects, virtual models, or any PORO that needs model-like behavior. Triggers on "active model", "form object", "plain Ruby object", "non-database model", "ActiveModel", "PORO", "service object validation", "virtual model", "tableless model", "search form", "ActiveModel::API", "ActiveModel::Model".

UncategorizedID: thinkoodle/rails-skills/active-model

Install this agent skill to your local

pnpm dlx add-skill https://github.com/ThinkOodle/rails-skills/tree/HEAD/skills/active-model

Skill Files

Browse the full folder contents for active-model.

Download Skill

Loading file tree…

skills/active-model/SKILL.md

Skill Metadata

Name
active-model
Description
Build plain Ruby objects that integrate with Rails forms, validations, serialization, and Action Pack — without a database. Use when building form objects, search forms, API wrappers, configuration objects, virtual models, or any PORO that needs model-like behavior. Triggers on "active model", "form object", "plain Ruby object", "non-database model", "ActiveModel", "PORO", "service object validation", "virtual model", "tableless model", "search form", "ActiveModel::API", "ActiveModel::Model".

Active Model Expert

Build model-like Ruby classes that work with Rails forms, validations, and serialization — without touching a database.

Core Decision: Active Model vs Active Record

Use Active Model when:

  • No database table backs the object
  • Form objects that aggregate multiple models
  • Search/filter forms
  • API request/response wrappers
  • Configuration or settings objects
  • Contact forms, invite forms, onboarding wizards
  • Decorators or presenters needing validation
  • Service objects that need validation + error messages

Use Active Record when:

  • Data must persist in a database
  • You need associations, scopes, or query interface
  • You need migrations and schema management

The #1 agent mistake: Reaching for Active Record (or raw POROs with hand-rolled validation) when Active Model gives you everything Rails forms and controllers expect — for free.

Module Hierarchy

Understanding which module to include is critical:

| Module | What You Get | When To Use | |--------|-------------|-------------| | ActiveModel::Model | API + future extensions | Default choice — use this | | ActiveModel::API | Validations, Naming, Conversion, Translation, AttributeAssignment | Lightweight alternative to Model | | ActiveModel::Attributes | Typed attributes with casting + defaults | Need type coercion (dates, booleans, integers) | | ActiveModel::Validations | Just validations | Adding validation to any object | | ActiveModel::Callbacks | Lifecycle hooks (before/after/around) | Need callback chains | | ActiveModel::Dirty | Change tracking | Track attribute modifications | | ActiveModel::Serialization | serializable_hash | Need hash/JSON output | | ActiveModel::SecurePassword | bcrypt password handling | Password without Active Record |

Key insight: ActiveModel::Model includes ActiveModel::API, which bundles Validations, Naming, Conversion, Translation, and AttributeAssignment. Start with Model and add other modules as needed.

Instructions

Step 1: Choose the Right Base

For most form objects and virtual models — use ActiveModel::Model:

class ContactForm
  include ActiveModel::Model

  attr_accessor :name, :email, :message

  validates :name, :email, :message, presence: true
  validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }

  def submit
    return false unless valid?
    ContactMailer.new_message(name:, email:, message:).deliver_later
    true
  end
end

This works with form_with, render, and all Action View helpers immediately.

When you need typed attributes — add ActiveModel::Attributes:

class SearchForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :query, :string
  attribute :min_price, :decimal
  attribute :max_price, :decimal
  attribute :available_only, :boolean, default: false
  attribute :created_after, :date

  validates :query, length: { minimum: 2 }, allow_blank: true

  def results
    scope = Product.all
    scope = scope.where("name ILIKE ?", "%#{query}%") if query.present?
    scope = scope.where("price >= ?", min_price) if min_price.present?
    scope = scope.where("price <= ?", max_price) if max_price.present?
    scope = scope.where(available: true) if available_only
    scope = scope.where("created_at >= ?", created_after) if created_after.present?
    scope
  end
end

Attributes gives you automatic type casting — string "true" becomes boolean true, string "2024-01-15" becomes a Date.

Step 2: Wire Into Controllers

Active Model objects work exactly like Active Record in controllers:

class ContactFormsController < ApplicationController
  def new
    @contact_form = ContactForm.new
  end

  def create
    @contact_form = ContactForm.new(contact_form_params)
    if @contact_form.submit
      redirect_to root_path, notice: "Message sent!"
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def contact_form_params
    params.require(:contact_form).permit(:name, :email, :message)
  end
end

Step 3: Use with Forms

<%= form_with model: @contact_form, url: contact_forms_path do |f| %>
  <% if @contact_form.errors.any? %>
    <div id="errors">
      <% @contact_form.errors.full_messages.each do |msg| %>
        <p><%= msg %></p>
      <% end %>
    </div>
  <% end %>

  <%= f.text_field :name %>
  <%= f.email_field :email %>
  <%= f.text_area :message %>
  <%= f.submit "Send" %>
<% end %>

Key: You must provide url: in form_with since Active Model objects aren't routable by default (no persisted? returning true, no id).

Step 4: Add Modules As Needed

Only include what you actually use. Don't cargo-cult every module.

Callbacks — when you need lifecycle hooks:

class RegistrationForm
  include ActiveModel::Model
  include ActiveModel::Attributes
  extend ActiveModel::Callbacks

  define_model_callbacks :save

  attribute :email, :string
  attribute :name, :string
  attribute :company_name, :string

  before_save :normalize_email
  after_save :send_welcome_email

  def save
    return false unless valid?
    run_callbacks(:save) do
      create_records!
    end
    true
  end

  private

  def normalize_email
    self.email = email.downcase.strip
  end

  def send_welcome_email
    WelcomeMailer.registration(@user).deliver_later
  end

  def create_records!
    @company = Company.create!(name: company_name)
    @user = @company.users.create!(email:, name:)
  end
end

Important: extend (not include) for Callbacks. And you must call run_callbacks(:event) { ... } yourself — Active Model doesn't auto-invoke them.

Dirty tracking — when you need change detection:

class Settings
  include ActiveModel::Model
  include ActiveModel::Dirty

  define_attribute_methods :theme, :language

  def theme
    @theme
  end

  def theme=(value)
    theme_will_change! unless value == @theme
    @theme = value
  end

  def language
    @language
  end

  def language=(value)
    language_will_change! unless value == @language
    @language = value
  end

  def save
    changes_applied
  end

  def reload!
    clear_changes_information
  end
end

Serialization — for JSON APIs:

class ApiResponse
  include ActiveModel::Model
  include ActiveModel::Serializers::JSON

  attr_accessor :status, :data, :timestamp

  def attributes
    { "status" => nil, "data" => nil, "timestamp" => nil }
  end
end

response = ApiResponse.new(status: "ok", data: { count: 42 }, timestamp: Time.current)
response.as_json  # => {"status"=>"ok", "data"=>{"count"=>42}, "timestamp"=>"2024-..."}
response.to_json  # => '{"status":"ok",...}'

SecurePassword — password handling without Active Record:

class SessionForm
  include ActiveModel::Model
  include ActiveModel::SecurePassword

  has_secure_password

  attr_accessor :password_digest

  def authenticate_user(email, password)
    user = User.find_by(email:)
    user&.authenticate(password)
  end
end

Requires the bcrypt gem. Provides password, password_confirmation, and authenticate methods.

Step 5: Naming and Translation

ActiveModel::Model includes Naming and Translation automatically.

Customize model name (useful for namespaced classes):

module Admin
  class InviteForm
    include ActiveModel::Model

    def self.model_name
      ActiveModel::Name.new(self, nil, "InviteForm")
    end
  end
end

# Without override: form params would be admin_invite_form[email]
# With override:    form params are invite_form[email]

I18n for attribute names:

# config/locales/en.yml
en:
  activemodel:
    attributes:
      contact_form:
        name: "Full Name"
        email: "Email Address"
    errors:
      models:
        contact_form:
          attributes:
            email:
              invalid: "doesn't look right"

Step 6: Test Active Model Objects

require "test_helper"

class ContactFormTest < ActiveSupport::TestCase
  test "valid with all attributes" do
    form = ContactForm.new(name: "Jane", email: "jane@example.com", message: "Hello")
    assert form.valid?
  end

  test "invalid without name" do
    form = ContactForm.new(email: "jane@example.com", message: "Hello")
    refute form.valid?
    assert_includes form.errors[:name], "can't be blank"
  end

  test "invalid with bad email" do
    form = ContactForm.new(name: "Jane", email: "not-an-email", message: "Hello")
    refute form.valid?
    assert_includes form.errors[:email], "is invalid"
  end

  test "#submit delivers email when valid" do
    form = ContactForm.new(name: "Jane", email: "jane@example.com", message: "Hello")
    assert_enqueued_emails 1 do
      assert form.submit
    end
  end

  test "#submit returns false when invalid" do
    form = ContactForm.new
    refute form.submit
  end
end

Lint tests — verify API compliance:

class ContactFormLintTest < ActiveSupport::TestCase
  include ActiveModel::Lint::Tests

  setup do
    @model = ContactForm.new
  end
end

Common Patterns

Form Object (Multi-Model)

class RegistrationForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :user_email, :string
  attribute :user_name, :string
  attribute :company_name, :string
  attribute :plan, :string, default: "free"

  validates :user_email, :user_name, :company_name, presence: true
  validates :user_email, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :plan, inclusion: { in: %w[free pro enterprise] }

  def save
    return false unless valid?

    ActiveRecord::Base.transaction do
      company = Company.create!(name: company_name)
      company.users.create!(email: user_email, name: user_name)
      company.subscriptions.create!(plan:)
    end
    true
  rescue ActiveRecord::RecordInvalid => e
    errors.add(:base, e.message)
    false
  end
end

Search/Filter Form

class OrderSearch
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :status, :string
  attribute :customer_name, :string
  attribute :date_from, :date
  attribute :date_to, :date
  attribute :min_total, :decimal
  attribute :sort_by, :string, default: "created_at"
  attribute :sort_direction, :string, default: "desc"

  def results
    scope = Order.includes(:customer)
    scope = scope.where(status:) if status.present?
    scope = scope.joins(:customer).where("customers.name ILIKE ?", "%#{customer_name}%") if customer_name.present?
    scope = scope.where("orders.created_at >= ?", date_from) if date_from.present?
    scope = scope.where("orders.created_at <= ?", date_to) if date_to.present?
    scope = scope.where("orders.total >= ?", min_total) if min_total.present?
    scope = scope.order(sort_by => sort_direction)
    scope
  end
end

Configuration Object

class NotificationPreferences
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::Serializers::JSON

  attribute :email_enabled, :boolean, default: true
  attribute :sms_enabled, :boolean, default: false
  attribute :digest_frequency, :string, default: "daily"
  attribute :quiet_hours_start, :integer, default: 22
  attribute :quiet_hours_end, :integer, default: 8

  validates :digest_frequency, inclusion: { in: %w[realtime hourly daily weekly] }
  validates :quiet_hours_start, :quiet_hours_end,
            numericality: { in: 0..23 }

  def attributes
    {
      "email_enabled" => nil,
      "sms_enabled" => nil,
      "digest_frequency" => nil,
      "quiet_hours_start" => nil,
      "quiet_hours_end" => nil
    }
  end

  def quiet_now?
    hour = Time.current.hour
    if quiet_hours_start > quiet_hours_end
      hour >= quiet_hours_start || hour < quiet_hours_end
    else
      hour >= quiet_hours_start && hour < quiet_hours_end
    end
  end
end

Anti-Patterns

  1. Using Active Record for non-persisted objects — If there's no table, don't subclass ApplicationRecord
  2. Hand-rolling validations — Don't write raise "Name required" if name.blank? when validates :name, presence: true exists
  3. Skipping ActiveModel::Model — Don't manually implement initialize(attrs={}) with hash iteration; Model does it
  4. Including everything — Only include modules you use; Model is enough for most cases
  5. Forgetting url: in form_with — Active Model objects don't auto-resolve routes
  6. Using extend where include is needed — Callbacks use extend; everything else uses include
  7. Not wrapping multi-model saves in transactions — Form objects that create multiple records need ActiveRecord::Base.transaction
  8. Reimplementing assign_attributesActiveModel::Model already gives you attribute assignment from a hash via the initializer

Reference

For detailed patterns, edge cases, and advanced usage, see the references/ directory:

  • references/api-and-attributes.md — Model vs API, typed attributes, naming, translation, conversion
  • references/validations-and-callbacks.md — All validators, custom validators, callbacks, error handling
  • references/dirty-tracking.md — Manual and automatic change tracking
  • references/serialization.md — serializable_hash, JSON serialization
  • references/patterns.md — Form objects, wizards, service objects, lint tests, edge cases