active-model
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".
How do I install this agent skill?
npx skills add https://github.com/thinkoodle/rails-skills --skill active-modelIs this agent skill safe to install?
- Gen Agent Trust Hubpass
The skill provides comprehensive documentation and code examples for using the Ruby on Rails Active Model library. No security issues were detected.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
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
- Using Active Record for non-persisted objects — If there's no table, don't subclass
ApplicationRecord - Hand-rolling validations — Don't write
raise "Name required" if name.blank?whenvalidates :name, presence: trueexists - Skipping
ActiveModel::Model— Don't manually implementinitialize(attrs={})with hash iteration;Modeldoes it - Including everything — Only include modules you use;
Modelis enough for most cases - Forgetting
url:inform_with— Active Model objects don't auto-resolve routes - Using
extendwhereincludeis needed — Callbacks useextend; everything else usesinclude - Not wrapping multi-model saves in transactions — Form objects that create multiple records need
ActiveRecord::Base.transaction - Reimplementing
assign_attributes—ActiveModel::Modelalready 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, conversionreferences/validations-and-callbacks.md— All validators, custom validators, callbacks, error handlingreferences/dirty-tracking.md— Manual and automatic change trackingreferences/serialization.md— serializable_hash, JSON serializationreferences/patterns.md— Form objects, wizards, service objects, lint tests, edge cases
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/thinkoodle/rails-skills/active-model">View active-model on skillZs</a>