Rails Active Model: Beyond Active Record
Learn how Rails Active Model powers form objects, validations, and typed attributes without requiring database persistence.
By Jean Emmanuel Cadet • 24 min read
Not every object in a Rails application needs a database table. A contact form, a product search with five filters, a signup wizard, or the input for an API operation all need validation, readable error messages, and form helper support. None of them needs to be stored as a row in your database.
Rails Active Model is the part of the framework that provides this model-like behavior to plain Ruby objects. This guide explains what Active Model is, how its modules fit together, and how to use it for form objects, search filters, multi-step forms, and service inputs. It also covers testing and the mistakes to avoid.
What Is Active Model in Rails?
Active Model is a Rails library that defines the interfaces and behaviors Rails expects from a “model”: validations, naming, conversion, translation, attribute handling, serialization, dirty tracking, and callbacks. It has no knowledge of databases. It only describes how a model-like object should behave.
Active Record, the ORM most Rails developers know, builds on several Active Model modules and adds database persistence on top. Active Model lets you use the same building blocks without the database layer.
Why Active Model Exists
Rails form helpers, error message generation, and internationalization all expect objects that respond to a certain set of methods. Active Model separates that contract from persistence, so any Ruby object can opt in to the behavior it needs.
A Practical Example: A Contact Form
Imagine a “Contact us” page. The visitor enters a name, email, and message, and your app sends an email. You never need to query these messages later.
The habit is to reach for a generator:
bin/rails generate model ContactMessage name:string email:string message:text
bin/rails db:migrate
That gives you a migration, a table, and a model that you must maintain, even though nothing is ever stored. If you only email the message, the table is dead weight.
An Active Model object gives you validations and form support with no table:
# app/models/contact_message.rb
class ContactMessage
include ActiveModel::Model
attr_accessor :name, :email, :message
validates :name, presence: true
validates :email, presence: true
validates :message, presence: true
end
We will walk through this class in detail below.
Common Use Cases
Active Model is a good fit for:
- Contact and feedback forms that trigger an email or a notification.
- Search forms with several filters that do not map to one table.
- Multi-step forms that collect data across several requests.
- API input objects that validate incoming parameters.
- Configuration objects that need validation and defaults.
- Temporary application state, such as a filter preset or an import preview.
If you want a deeper look at the pattern behind many of these, read our guide to Rails form objects and when to use them.
Active Model vs. Active Record
The simplest way to separate them: Active Model describes what a model looks like, and Active Record adds where it is stored.
What Active Record Adds
Active Record is Rails’ object-relational mapper. It:
- Maps classes to database tables and attributes to columns.
- Provides
save,update,destroy, and finders such asfindandwhere. - Defines associations such as
has_manyandbelongs_to. - Handles migrations, transactions, and database-backed validators such as
uniqueness.
What Active Model Provides
Active Model provides the model interface:
- Validations and error collections.
- Naming (
model_name) and conversion (to_model,to_key,to_param). - Typed attributes and casting through
ActiveModel::Attributes. - Serialization, dirty tracking, callbacks, and translations.
- Compatibility with Rails form helpers.
Active Record itself uses Active Model. Validations, naming, conversion, serialization, dirty tracking, and callbacks in Active Record models come from, or are built on, Active Model modules. That is why a plain Active Model object feels familiar: it uses the same foundations.
Side-by-Side Comparison
Concern | Active Record | Active Model |
|---|---|---|
Database persistence | Yes | No |
Associations and queries | Yes | No |
| Provided | You implement them if needed |
Validations | Yes | Yes |
Naming and conversion | Yes | Yes |
Typed attributes | Yes (from table columns or | Yes (with |
Form helper support | Yes | Yes |
Needs a table | Yes | No |
Active Model is not a replacement for Active Record. If your data must be stored, queried, joined, or related to other records, use Active Record. Active Model fits objects that need model behavior without a table behind them.
Understanding the Active Model Modules
Active Model is a collection of small modules. You include only what an object needs. Here is what each one does:
Module | Purpose | Included by |
|---|---|---|
| Convenience module that includes | n/a |
| Bundles the basics for form-compatible objects | n/a |
|
| Yes |
|
| Yes (the class is extended with it) |
|
| Yes |
| I18n lookup for attribute and model names | Yes (the class is extended with it) |
| Typed attributes with casting and defaults | No |
|
| No |
| Change tracking | No |
| Custom lifecycle callbacks | No |
How the Modules Compose
ActiveModel::API arrived in Rails 7.0 and bundles hash-based initialization, validations, naming, translation, and conversion. It also defines persisted? to return false. ActiveModel::Model includes ActiveModel::API, so for most form objects the two are interchangeable. For a typical form object, either works.
The other modules are not pulled in automatically. If you want typed attributes, serialization, dirty tracking, or callbacks, you must include (or extend) the relevant module yourself. Some capabilities also overlap. For example, ActiveModel::Serialization expects an attributes method, and ActiveModel::Attributes provides one. The exact composition can vary between Rails versions, so check the API documentation for the version you run. The examples in this article target Rails 8.1.
A Quick Look at Serialization
ActiveModel::Serialization turns an object into a hash. It needs an attributes method that returns a hash with string keys, which ActiveModel::Attributes already supplies:
class SearchForm
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Serialization
attribute :query, :string
attribute :page, :integer, default: 1
end
SearchForm.new(query: "rails").serializable_hash
# => {"query"=>"rails", "page"=>1}
A Quick Look at Translation
ActiveModel::Translation lets you customize model and attribute names through I18n using the activemodel scope:
# config/locales/en.yml
en:
activemodel:
models:
contact_message: Contact message
attributes:
contact_message:
email: Email address
With this in place, form labels and full error messages show “Email address” instead of “Email”.
Create a Model-Like Ruby Object With ActiveModel::Model
Here is the ContactMessage class again:
# app/models/contact_message.rb
class ContactMessage
include ActiveModel::Model
attr_accessor :name, :email, :message
validates :name, presence: true
validates :email, presence: true
validates :message, presence: true
end
What include ActiveModel::Model Gives You
Including the module provides:
- An initializer that accepts a hash of attributes.
- Validation methods such as
validates,valid?, anderrors. model_name,to_model,to_key, andto_param, so form helpers know how to work with the object.- A
persisted?method that returnsfalse.
Why attr_accessor?
ActiveModel::Model does not define attributes for you. It only expects that your object has readers and writers. attr_accessor is plain Ruby and is enough here. Form helpers call the readers to fill in field values, and the initializer calls the writers to assign the hash you pass in.
Using the Object
contact = ContactMessage.new(
name: "Jean",
email: "[email protected]",
message: "I would like more information."
)
contact.valid?
# => true
contact.errors.full_messages
# => []
valid? runs every validation and returns true or false. It also fills the errors collection. errors.full_messages returns an array of readable strings. When everything is valid, it is empty.
With a missing email:
contact = ContactMessage.new(name: "Jean", message: "Hello")
contact.valid?
# => false
contact.errors.full_messages
# => ["Email can't be blank"]
What It Does Not Do
ActiveModel::Model does not provide save, update, find, or where, and nothing is written anywhere. If you call contact.save, you get a NoMethodError unless you have defined that method yourself. Persistence is your decision to make, and the next sections show how to handle it explicitly.
Understand Active Model Validations
Active Model includes the same validation framework used by Active Record, minus the validators that need a database.
Built-In Validators
class ContactMessage
include ActiveModel::Model
attr_accessor :name, :email, :message
validates :name, presence: true, length: { maximum: 80 }
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :message, presence: true, length: { minimum: 10, maximum: 2000 }
end
You can also use numericality, inclusion, exclusion, acceptance, and confirmation. The uniqueness validator is the exception. It is defined by Active Record because it must query the database, so it is not available on a plain Active Model object.
Conditional Validations
Use if: and unless: to run a rule only when it makes sense. This assumes the object also has reply_by_phone and phone attributes:
validates :phone, presence: true, if: :reply_by_phone?
Custom Validation Methods
Add this to ContactMessage to reject messages that contain links:
validate :message_must_not_contain_links
private
def message_must_not_contain_links
return if message.blank?
errors.add(:message, "must not contain links") if message.match?(%r{https?://}i)
end
A custom method is the better choice when a rule involves several attributes, depends on domain knowledge, or does not fit a built-in validator. If you reuse the same rule across several classes, extract it into a validator class. Our guide to Rails custom validators covers that approach.
Reading Errors
contact.errors[:email] # => ["can't be blank"]
contact.errors.full_messages_for(:email) # => ["Email can't be blank"]
contact.errors.of_kind?(:email, :blank) # => true
contact.errors.any? # => true
Validation Contexts
You can limit validations to a named context with on: and pass the context to valid?:
validates :email, presence: true, on: :preview
contact.valid?(:preview)
Validations without on: run in every context. Contexts are especially useful in multi-step forms, covered later.
Validations Are Not Database Constraints
Active Model validations run in Ruby, in a single process, before your code continues. They do not create database constraints. They cannot guarantee integrity when several application instances, background jobs, or other database clients write at the same time. When your data is persisted, keep database constraints such as NOT NULL, unique indexes, and foreign keys as the final safety net, and use validations for user-friendly feedback.
Use ActiveModel::Attributes for Typed Attributes
attr_accessor stores whatever you give it. ActiveModel::Attributes lets you declare an attribute with a type, so values are cast automatically. This matters because form and query string values always arrive as strings.
# app/forms/search_form.rb
class SearchForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :query, :string
attribute :page, :integer, default: 1
attribute :available_only, :boolean, default: false
end
Files in any subdirectory of app/, such as app/forms, are autoloaded by Rails, so no configuration is needed.
Type Casting and Defaults
form = SearchForm.new(query: "rails", page: "3", available_only: "1")
form.query # => "rails"
form.page # => 3
form.page.class # => Integer
form.available_only # => true
form.attributes
# => {"query"=>"rails", "page"=>3, "available_only"=>true}
SearchForm.new.page # => 1
SearchForm.attribute_types["page"].type # => :integer
Strings such as "3" and "1" were converted to an integer and a boolean. When you omit an attribute, its default is used.
How This Differs From attr_accessor
With attr_accessor, page would remain the string "3" and you would convert it by hand. Typed attributes centralize that work and make form objects and configuration objects predictable. They also provide the attributes hash used for serialization.
Casting Is Not Validation
Type casting converts values. It does not decide whether they are acceptable. Casting is also lenient: an invalid string such as "abc" becomes 0 for an integer attribute, and a blank string becomes nil. You still need validations to enforce rules such as “page must be greater than zero”:
validates :page, numericality: { only_integer: true, greater_than: 0 }Treat every value from a form, a query string, or an API as untrusted until it has been validated.
Integrate Active Model Objects With Rails Forms
Rails form helpers do not care whether an object is stored in a database. They care about the interface it provides, and ActiveModel::Model supplies that interface.
The Form
<%# app/views/contact_messages/new.html.erb %>
<%= form_with model: @contact_message, url: contact_messages_path do |form| %>
<div>
<%= form.label :name %>
<%= form.text_field :name %>
<% @contact_message.errors.full_messages_for(:name).each do |error| %>
<p><%= error %></p>
<% end %>
</div>
<div>
<%= form.label :email %>
<%= form.email_field :email %>
<% @contact_message.errors.full_messages_for(:email).each do |error| %>
<p><%= error %></p>
<% end %>
</div>
<div>
<%= form.label :message %>
<%= form.text_area :message %>
<% @contact_message.errors.full_messages_for(:message).each do |error| %>
<p><%= error %></p>
<% end %>
</div>
<%= form.submit "Send message" %>
<% end %>
This example assumes a matching route and controller actions exist. A minimal route looks like this:
# config/routes.rb
resources :contact_messages, only: %i[new create]
If routing is still new to you, see how Rails routing works.
The Controller
# app/controllers/contact_messages_controller.rb
class ContactMessagesController < ApplicationController
def new
@contact_message = ContactMessage.new
end
def create
@contact_message = ContactMessage.new(contact_message_params)
if @contact_message.valid?
ContactMailer.with(
name: @contact_message.name,
email: @contact_message.email,
message: @contact_message.message
).new_message.deliver_later
redirect_to root_path, notice: "Thanks, your message was sent."
else
render :new, status: :unprocessable_entity
end
end
private
def contact_message_params
params.expect(contact_message: [:name, :email, :message])
end
end
A few assumptions to note:
ContactMailerwith anew_messageaction is an application mailer you would write yourself.params.expectis available from Rails 8.0. On older versions, useparams.require(:contact_message).permit(:name, :email, :message). See understanding strong parameters in Rails for details.- Newer Rack versions prefer the status name
:unprocessable_content. Both express HTTP 422.
The controller never calls save. The object has no persistence method, so it validates, then hands the data to a mailer.
How the Integration Works
Form helpers rely on the naming and conversion modules:
model_name.param_keygivescontact_message, so fields are namedcontact_message[name],contact_message[email], and so on.- Field values come from the object’s readers, so a failed submission re-renders with what the user typed.
- Labels use the attribute names, which can be customized through I18n.
persisted?returnsfalse, so the form usesPOSTrather thanPATCH.
Why the URL Is Usually Explicit
For a persisted Active Record record, Rails can infer paths such as /articles/1 from the record’s id. A non-persistent object has no id, and your route may not follow a conventional resource name, such as a singular resource :contact. Passing url: removes any guesswork. Form integration never creates routes or persists data. It only builds the HTML and field names.
New Object vs. Persisted Record
When you edit a persisted Active Record record, form_with model: @article generates a PATCH request to the record’s URL with a hidden _method field. An Active Model object reports persisted? == false, so you get POST to the URL you supplied. You can define id and persisted? yourself to change this, but do that only when the object truly represents an existing resource.
Build a Search Form Object
Search pages often combine a text query, a category, a price range, a sort order, and pagination. These inputs do not map to one table, so a form object is a natural home for them.
The Form Object
# app/forms/search_form.rb
class SearchForm
include ActiveModel::Model
include ActiveModel::Attributes
SORT_OPTIONS = %w[newest price_asc price_desc].freeze
attribute :query, :string
attribute :category_id, :integer
attribute :min_price, :decimal
attribute :max_price, :decimal
attribute :available_only, :boolean, default: false
attribute :sort, :string, default: "newest"
attribute :page, :integer, default: 1
validates :query, length: { maximum: 100 }
validates :min_price, :max_price, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
validates :sort, inclusion: { in: SORT_OPTIONS }
validates :page, numericality: { only_integer: true, greater_than: 0 }
validate :price_range_must_be_consistent
def search_term
query.to_s.squish.presence
end
private
def price_range_must_be_consistent
return if min_price.blank? || max_price.blank?
if max_price < min_price
errors.add(:max_price, "must be greater than or equal to the minimum price")
end
end
end
This is an expanded version of the earlier SearchForm, with the same query, page, and available_only attributes plus a few more. Notice what the object does:
- It casts strings into the right types and applies defaults.
- It validates untrusted input, including a rule that spans two attributes.
- It normalizes the query through
search_term.
It does not touch the database.
The Query Object
The database work lives in a separate class:
# app/queries/product_search_query.rb
class ProductSearchQuery
PER_PAGE = 20
def initialize(criteria, relation: Product.all)
@criteria = criteria
@relation = relation
end
def call
scope = relation
if criteria.search_term
pattern = "%#{Product.sanitize_sql_like(criteria.search_term)}%"
scope = scope.where("products.name LIKE ?", pattern)
end
scope = scope.where(category_id: criteria.category_id) if criteria.category_id
scope = scope.where(price: criteria.min_price..) if criteria.min_price
scope = scope.where(price: ..criteria.max_price) if criteria.max_price
scope = scope.where(available: true) if criteria.available_only
sort(scope).limit(PER_PAGE).offset((criteria.page - 1) * PER_PAGE)
end
private
attr_reader :criteria, :relation
def sort(scope)
case criteria.sort
when "price_asc" then scope.order(price: :asc)
when "price_desc" then scope.order(price: :desc)
else scope.order(created_at: :desc)
end
end
end
This assumes a Product model with name, category_id, price, available, and created_at columns.
The Controller
# app/controllers/products_controller.rb
class ProductsController < ApplicationController
def index
@search_form = SearchForm.new(search_params)
@products =
if @search_form.valid?
ProductSearchQuery.new(@search_form).call
else
Product.none
end
end
private
def search_params
params.fetch(:search_form, {}).permit(
:query, :category_id, :min_price, :max_price, :available_only, :sort, :page
)
end
end
The view can use form_with model: @search_form, url: products_path, method: :get and show errors in the same way as the contact form.
Why Separate the Two Objects?
The form object validates and organizes input. The query object runs the search. Each is easy to read and test on its own. You can reuse the form object across an HTML page and a JSON endpoint, and change the query without touching validation rules.
Use Active Model for Multi-Step Forms
A signup wizard collects data across several pages, but you do not want to save a half-finished user. An Active Model object can hold the temporary input, validate each step, and create the real records only at the end.
A Wizard Object With Step Validations
# app/forms/signup_wizard.rb
class SignupWizard
include ActiveModel::Model
include ActiveModel::Attributes
attribute :email, :string
attribute :full_name, :string
attribute :company_name, :string
attribute :team_size, :integer
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }, on: :account
validates :full_name, presence: true, on: :account
validates :company_name, presence: true, on: :profile
validates :team_size, numericality: { only_integer: true, greater_than: 0 }, on: :profile
def complete?
valid?(:account) && valid?(:profile)
end
def save
return false unless complete?
ActiveRecord::Base.transaction do
user = User.create!(email: email, full_name: full_name)
user.create_company!(name: company_name, team_size: team_size)
end
true
rescue ActiveRecord::RecordInvalid => error
errors.add(:base, error.message)
false
end
end
Each step validates only its own fields by passing a context to valid?. The save method is explicit persistence that we wrote ourselves. It assumes User and Company Active Record models, with User having has_one :company. Keeping the wizard separate from those models means each persisted model can stay focused on its own rules.
When a final step has to coordinate several persisted objects, you can also build the records, check them together, and copy their errors onto the wizard. For parent and child records saved together, Rails nested attributes may be a better fit than a wizard.
Carrying State Between Requests
Active Model does not remember anything between HTTP requests. You must store the step data yourself, in the session, the database, a cache, or another suitable store:
# app/controllers/signup_steps_controller.rb
class SignupStepsController < ApplicationController
def account
@wizard = build_wizard
end
def save_account
@wizard = build_wizard(account_params)
if @wizard.valid?(:account)
session[:signup_wizard] = @wizard.attributes
redirect_to profile_signup_path
else
render :account, status: :unprocessable_entity
end
end
def profile
@wizard = build_wizard
end
def create
@wizard = build_wizard(profile_params)
if @wizard.save
session.delete(:signup_wizard)
redirect_to root_path, notice: "Welcome aboard."
else
render :profile, status: :unprocessable_entity
end
end
private
def build_wizard(attributes = {})
SignupWizard.new(session.fetch(:signup_wizard, {}).merge(attributes.to_h))
end
def account_params
params.expect(signup_wizard: [:email, :full_name])
end
def profile_params
params.expect(signup_wizard: [:company_name, :team_size])
end
end
This assumes routes for account, save_account, profile, and create, and views that use form_with model: @wizard with explicit URLs.
Security Considerations
- Never trust earlier steps. The final step re-validates everything through
complete?, because users can tamper with requests or skip steps. - Do not store sensitive data in the session. The cookie session is encrypted, but it has a size limit of about 4 KB and is sent with every request. Passwords and similar secrets do not belong in it.
- Use strong parameters at every step so only the fields for that step are accepted.
- Use server-side storage for larger or sensitive state. Store it in the database or cache under an unguessable token, and expire it.
Use Active Model in Service Objects and API Input Objects
Service objects often accept loosely structured input from controllers or API requests. An Active Model input object gives that input a predictable shape: declared attributes, validations, and an errors interface callers already understand. Our guide to Rails service objects covers the broader pattern.
An Input Object
# app/inputs/transfer_request.rb
class TransferRequest
include ActiveModel::Model
include ActiveModel::Attributes
attribute :source_account_id, :integer
attribute :destination_account_id, :integer
attribute :amount_cents, :integer
attribute :note, :string
validates :source_account_id, :destination_account_id, presence: true
validates :amount_cents, numericality: { only_integer: true, greater_than: 0 }
validates :note, length: { maximum: 140 }
validate :accounts_must_differ
private
def accounts_must_differ
return if source_account_id.blank? || destination_account_id.blank?
if source_account_id == destination_account_id
errors.add(:destination_account_id, "must be different from the source account")
end
end
end
A Separate Service
# app/services/transfer_funds.rb
class TransferFunds
class InsufficientFunds < StandardError; end
def self.call(transfer_request, user:)
new(transfer_request, user: user).call
end
def initialize(transfer_request, user:)
@transfer_request = transfer_request
@user = user
end
def call
return false unless transfer_request.valid?
ActiveRecord::Base.transaction do
source = user.accounts.lock.find(transfer_request.source_account_id)
destination = Account.lock.find(transfer_request.destination_account_id)
source.withdraw!(transfer_request.amount_cents)
destination.deposit!(transfer_request.amount_cents)
end
true
rescue InsufficientFunds => error
transfer_request.errors.add(:amount_cents, error.message)
false
end
private
attr_reader :transfer_request, :user
end
This example assumes an Account model with withdraw! and deposit! methods, where withdraw! raises TransferFunds::InsufficientFunds when the balance is too low.
Returning Errors to the Caller
# app/controllers/api/v1/transfers_controller.rb
module Api
module V1
class TransfersController < ApplicationController
def create
transfer_request = TransferRequest.new(transfer_params)
if TransferFunds.call(transfer_request, user: current_user)
head :created
else
render json: { errors: transfer_request.errors.to_hash },
status: :unprocessable_entity
end
end
private
def transfer_params
params.expect(transfer: [:source_account_id, :destination_account_id, :amount_cents, :note])
end
end
end
end
Both input validation errors and business errors end up in the same errors object, so the response format stays consistent.
What Active Model Does Not Do Here
Including Active Model does not make TransferRequest a service object. The service is a separate class that performs the operation. Active Model also does not replace authorization (the service scopes the source account through user.accounts), transaction handling (the service wraps the work in a transaction), or business rules such as sufficient balances. It only validates and structures the input.
Understand Active Model Naming and Conversion
Rails uses a small set of methods to decide how an object appears in forms and URLs. Active Model supplies them through two modules.
Naming
ActiveModel::Naming adds model_name to the class:
ContactMessage.model_name.name # => "ContactMessage"
ContactMessage.model_name.param_key # => "contact_message"
ContactMessage.model_name.route_key # => "contact_messages"
ContactMessage.model_name.singular_route_key # => "contact_message"
ContactMessage.model_name.human # => "Contact message"
param_key becomes the prefix for form field names. route_key and singular_route_key are used when Rails builds route names. human and i18n_key support labels and translations.
Conversion
ActiveModel::Conversion provides the methods Rails calls on objects it receives:
message = ContactMessage.new
message.to_model.equal?(message) # => true
message.persisted? # => false
message.to_key # => nil
message.to_param # => nil
to_modelreturns the object that Rails should treat as the model, usuallyself.to_keyreturns an array of key values, ornilwhen the object has noid.to_paramreturns the string used in URLs. It isnilfor an object that is not persisted.persisted?tells form helpers whether the object is new or existing.
Non-persistent objects typically report persisted? == false and have no database-generated identifier, which is why they cannot build member URLs like /contact_messages/1.
These behaviors depend on what you include and what you override. If you define id and persisted? yourself, Rails will treat the object as persisted, switch forms to PATCH, and use to_param in generated URLs. Override them only when the object genuinely represents something with an identity.
Use Active Model Callbacks and Dirty Tracking
Callbacks and dirty tracking are optional. A plain Ruby object, or even an object with ActiveModel::Model, gets neither automatically. You must include the right module and complete its setup.
ActiveModel::Callbacks
ActiveModel::Callbacks lets you define your own callback events. You extend the module, declare the events, and wrap the action in run_callbacks:
class NewsletterSubscription
extend ActiveModel::Callbacks
include ActiveModel::Model
include ActiveModel::Attributes
define_model_callbacks :subscribe
attribute :email, :string
validates :email, presence: true
before_subscribe :normalize_email
def subscribe
return false unless valid?
run_callbacks(:subscribe) do
NewsletterClient.add(email)
end
true
end
private
def normalize_email
self.email = email.to_s.strip.downcase
end
end
define_model_callbacks :subscribe creates before_subscribe, around_subscribe, and after_subscribe. The callbacks only run because subscribe calls run_callbacks. NewsletterClient stands in for whatever service you use to add subscribers. If you want before_validation and after_validation, include ActiveModel::Validations::Callbacks.
Callbacks can hide control flow. For a step as simple as normalizing an email, calling a method directly at the start of subscribe is often clearer. Use callbacks sparingly, and prefer explicit business logic.
ActiveModel::Dirty
ActiveModel::Dirty tracks attribute changes. Include it before declaring typed attributes so the change-tracking methods are generated for each attribute:
class AppSettings
include ActiveModel::Model
include ActiveModel::Dirty
include ActiveModel::Attributes
attribute :theme, :string, default: "light"
attribute :items_per_page, :integer, default: 25
validates :items_per_page, numericality: { only_integer: true, greater_than: 0 }
def save
return false unless valid?
# Persist the settings here (for example, to a cache or a config store).
changes_applied
true
end
end
settings = AppSettings.new
settings.theme = "dark"
settings.changed? # => true
settings.theme_changed? # => true
settings.changes # => {"theme"=>["light", "dark"]}
settings.save
settings.changed? # => false
Dirty tracking only knows about changes. You decide when they become permanent, which is why save calls changes_applied. This is useful for a configuration object where you want to detect modifications, but it does not give you the full set of persistence-related behavior that Active Record models have.
Test Active Model Objects
Active Model objects are plain Ruby classes, so their tests are fast and focused. The examples below use Minitest, the Rails default, and need no records to be created.
Valid and Invalid Input
# test/models/contact_message_test.rb
require "test_helper"
class ContactMessageTest < ActiveSupport::TestCase
test "is valid with a name, email, and message" do
contact_message = ContactMessage.new(
name: "Jean",
email: "[email protected]",
message: "I would like more information."
)
assert contact_message.valid?
end
test "is invalid without an email" do
contact_message = ContactMessage.new(name: "Jean", message: "Hello there, friend.")
assert_not contact_message.valid?
assert_includes contact_message.errors[:email], "can't be blank"
assert contact_message.errors.of_kind?(:email, :blank)
end
test "rejects messages that contain links" do
contact_message = ContactMessage.new(
name: "Jean",
email: "[email protected]",
message: "Visit https://example.com for details."
)
assert_not contact_message.valid?
assert_includes contact_message.errors[:message], "must not contain links"
end
test "uses the expected form naming and is not persisted" do
assert_equal "contact_message", ContactMessage.model_name.param_key
assert_not ContactMessage.new.persisted?
end
end
Type Casting, Defaults, and Custom Validations
# test/forms/search_form_test.rb
require "test_helper"
class SearchFormTest < ActiveSupport::TestCase
test "casts string values to the declared types" do
form = SearchForm.new(page: "3", available_only: "1")
assert_equal 3, form.page
assert_equal true, form.available_only
end
test "applies defaults" do
form = SearchForm.new
assert_equal 1, form.page
assert_equal "newest", form.sort
assert_equal false, form.available_only
end
test "requires a positive page number" do
form = SearchForm.new(page: "abc")
assert_not form.valid?
assert form.errors.of_kind?(:page, :greater_than)
end
test "rejects a maximum price lower than the minimum price" do
form = SearchForm.new(min_price: "50", max_price: "10")
assert_not form.valid?
assert_includes form.errors[:max_price], "must be greater than or equal to the minimum price"
end
end
The second-to-last test also shows why validation matters: "abc" casts to 0, and the validation catches it.
Controller Behavior
# test/controllers/contact_messages_controller_test.rb
require "test_helper"
class ContactMessagesControllerTest < ActionDispatch::IntegrationTest
test "sends a message and redirects on success" do
assert_enqueued_emails 1 do
post contact_messages_path, params: {
contact_message: {
name: "Jean",
email: "[email protected]",
message: "I would like more information."
}
}
end
assert_redirected_to root_path
end
test "re-renders the form when the submission is invalid" do
assert_no_enqueued_emails do
post contact_messages_path, params: {
contact_message: { name: "", email: "", message: "" }
}
end
assert_response :unprocessable_entity
end
end
Because these objects do not depend on database records, the unit tests run in milliseconds and stay focused on one behavior each. The controller tests check both the success and failure paths without needing a persisted model.
Common Active Model Mistakes to Avoid
- Using Active Record when no persistence is required. If you never store or query the data, you do not need a table. Start with
ActiveModel::Model. - Assuming Active Model provides persistence. It does not. If your object needs to be stored, write the persistence code explicitly or use Active Record.
- Expecting
save,update, or queries to exist. These methods are not part of Active Model. Define a method such assaveorsubmityourself if you need one. - Forgetting to validate submitted attributes. Form and API input is untrusted. Add validations, and permit parameters with strong parameters.
- Assuming type casting guarantees valid input. Casting converts values, so
"abc"becomes0for an integer. Validate the result. - Mixing form validation with unrelated business logic. Keep input rules in the form object and business operations in a service, as in the transfer example.
- Assuming form state persists across requests. It does not. Store multi-step data in the session, database, or cache.
- Including unnecessary modules. Add
Attributes,Dirty,Callbacks, orSerializationonly when the object needs them. - Overusing callbacks. If a method call makes the flow clearer, prefer it over a callback.
- Treating validations as database constraints. Ruby validations do not protect against concurrent writes or other database clients. Persisted data still needs constraints.
- Expecting resource URLs to be generated automatically. A non-persistent object has no id and no member route. Provide
url:explicitly inform_with.
Active Model Best Practices
- Use Active Model when an object needs model-like behavior but no database persistence. Forms, filters, and input objects are the typical cases.
- Start with
ActiveModel::Modelfor simple form and input objects. It gives you initialization, validations, naming, and conversion in one include. - Add
ActiveModel::Attributeswhen typed attributes and defaults help. It is especially useful for query string and form input. - Keep each object focused on one responsibility. A search form should not also send emails.
- Validate untrusted input explicitly. Do not rely on type casting to reject bad values.
- Separate input validation from database queries and business operations. Use form objects for input, query objects for searching, and services for actions.
- Include only the modules the object needs. Fewer modules mean less behavior to understand and fewer surprises.
- Keep persistence responsibilities explicit. If an object saves something, make that visible in a clearly named method.
- Test validation and form behavior independently. These tests are fast and do not need database records.
- Prefer clear, conventional Ruby over unnecessary abstractions. If an Active Model object does not make the code simpler, it probably is not needed.
The best design depends on your application. Active Model should simplify your code, not introduce complexity for its own sake.
Conclusion
Active Model extends the usefulness of Ruby objects in Rails far beyond database-backed Active Record models. With ActiveModel::Model, you get validations, naming, conversion, and form helper support. With ActiveModel::Attributes, you get typed attributes and defaults. Optional modules for serialization, dirty tracking, and callbacks are available when you need them.
It is especially valuable for form objects, search filters, input validation, and other objects that need Rails conventions without database persistence. Persistence, routes, authorization, and business rules remain your responsibility, which keeps each class focused and easy to test.
Ready to keep building? Explore more practical Ruby on Rails tutorials on CodeCurious and keep turning curiosity into clean, working code.