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

Rails Active Model: Beyond Active Record
Share with friends

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 as find and where.
  • Defines associations such as has_many and belongs_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

save, update, destroy

Provided

You implement them if needed

Validations

Yes

Yes

Naming and conversion

Yes

Yes

Typed attributes

Yes (from table columns or attribute)

Yes (with ActiveModel::Attributes)

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 ActiveModel::API / ActiveModel::Model?

ActiveModel::Model

Convenience module that includes ActiveModel::API

n/a

ActiveModel::API

Bundles the basics for form-compatible objects

n/a

ActiveModel::Validations

validates, valid?, errors

Yes

ActiveModel::Naming

model_name and naming helpers

Yes (the class is extended with it)

ActiveModel::Conversion

to_model, to_key, to_param

Yes

ActiveModel::Translation

I18n lookup for attribute and model names

Yes (the class is extended with it)

ActiveModel::Attributes

Typed attributes with casting and defaults

No

ActiveModel::Serialization

serializable_hash

No

ActiveModel::Dirty

Change tracking

No

ActiveModel::Callbacks

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?, and errors.
  • model_name, to_model, to_key, and to_param, so form helpers know how to work with the object.
  • A persisted? method that returns false.

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:

  • ContactMailer with a new_message action is an application mailer you would write yourself.
  • params.expect is available from Rails 8.0. On older versions, use params.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_key gives contact_message, so fields are named contact_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? returns false, so the form uses POST rather than PATCH.

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_model returns the object that Rails should treat as the model, usually self.
  • to_key returns an array of key values, or nil when the object has no id.
  • to_param returns the string used in URLs. It is nil for 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 as save or submit yourself 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" becomes 0 for 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, or Serialization only 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 in form_with.

Active Model Best Practices

  1. Use Active Model when an object needs model-like behavior but no database persistence. Forms, filters, and input objects are the typical cases.
  2. Start with ActiveModel::Model for simple form and input objects. It gives you initialization, validations, naming, and conversion in one include.
  3. Add ActiveModel::Attributes when typed attributes and defaults help. It is especially useful for query string and form input.
  4. Keep each object focused on one responsibility. A search form should not also send emails.
  5. Validate untrusted input explicitly. Do not rely on type casting to reject bad values.
  6. Separate input validation from database queries and business operations. Use form objects for input, query objects for searching, and services for actions.
  7. Include only the modules the object needs. Fewer modules mean less behavior to understand and fewer surprises.
  8. Keep persistence responsibilities explicit. If an object saves something, make that visible in a clearly named method.
  9. Test validation and form behavior independently. These tests are fast and do not need database records.
  10. 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.

Jean Emmanuel Cadet
Written by Jean Emmanuel Cadet
Jean Emmanuel is a Full-Stack Software Engineer specializing in Ruby on Rails and the modern Rails ecosystem. He builds scalable, maintainable web applications using Rails, Hotwire (Turbo & Stimulus), PostgreSQL, and SQLite, with a focus on fast, dynamic user experiences. Through CodeCurious, he shares practical lessons, development insights, and real-world solutions for modern developers.

Code. Learn. Grow.

A friendly newsletter sharing dev tips, lessons, and wins from my journey.