Ruby on Rails Module 4 – Views, forms and assets
Active Model and form objects
Give a plain Ruby class ActiveModel::API, typed attributes and the 8.1 normalizes macro, then save one screen's three records in a single transaction.
What you will learn
- Build a plain Ruby model with ActiveModel::API, typed attributes and validations
- Normalize input with the normalizes macro and leave the decision to accept it to a validation
- Use a form object to save one screen's records in a single transaction that rolls back on a refused write
- Respond to a text/markdown request from a plain object with render markdown
- Prove a form object's rules with a unit test that needs no server, browser or database
Before you start
What you’ll learn · Before you start
What you will learn
- Build a plain Ruby model with ActiveModel::API, typed attributes and validations
- Normalize input with the normalizes macro and leave the decision to accept it to a validation
- Use a form object to save one screen's records in a single transaction that rolls back on a refused write
- Respond to a text/markdown request from a plain object with render markdown
- Prove a form object's rules with a unit test that needs no server, browser or database
Before you start
On this page
Kaksha’s enrolment screen writes three records at once, a Student, a Guardian and a FeePlan, and all three
must be saved or none. It also checks things no single table owns, such as whether the fee plan a parent picked
exists. Put that work in the controller and the action fills with orchestration; put it in one of the models and the
other two records are out of its reach. A form object: A plain Ruby class that owns one screen's fields and rules: it validates across models, saves the records a successful submit needs, and leaves the controller with a single call to save. is a plain Ruby class that owns the
screen instead: it validates the three records together and writes them in one transaction.
Active Model: The Rails library of model behaviour a plain Ruby class can mix in: attribute assignment, naming, conversion, translation, validations and typed attributes, which Rails 8.1 can also normalize. It never touches a database. is what lets such a class act like a model with no table behind it: the
part of Rails that supplies naming, typed attributes, validations and an errors object, which form_with and the
error helpers already understand, and since Rails 8.1 the normalizes macro as well. The programs below build
EnrolmentForm on it, save it against an in-memory database, test it without a server, and answer a text/markdown
request from a plain notice object.
Active Model turns a plain class into a model
include ActiveModel::API gives a class attribute assignment, conversion, naming, translation and validations. The
Active Model Basics guide says any class that includes
it can be used with form_with, render and the other Action View helpers just like an Active Record object.
ActiveModel::Model, the module the guide recommends for model-like classes, is ActiveModel::API plus slice and
values_at, so either one works here. Either way the class holds the fields its screen cares about, not the columns
a table happens to have. Step through what each module adds:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A class with nothing included. A plain class with attr_accessor fields has no model name, no validations and no cast from the strings a form posts, so form_with has nothing to build a form from.
- include ActiveModel::API. ActiveModel::API brings attribute assignment, conversion, naming, translation and validations. The class now answers model_name, to_model, persisted? (false) and valid?, and keeps an errors object.
- form_with can name the fields. Naming gives the class the param key enrolment_form, so form_with model: EnrolmentForm.new names a field enrolment_form[student_name]. The run printed that key and persisted? false.
- include ActiveModel::Attributes. Attributes adds typed fields such as attribute :student_grade, :integer and a fee_plan whose default is "monthly". In the run a posted "10" became 10, a posted "" became nil and the default filled in.
- A model with no table behind it. With both included, the form object takes a hash of posted fields, casts each one to its type, runs its validations and reports through errors, all without a table or a database connection.
ActiveModel::Attributes, the scene’s second include, adds typed fields: attribute :student_grade, :integer
casts the string a browser posts, and attribute :fee_plan, :string, default: "monthly" fills in a value when
nothing is sent. Its API documentation
compares them with Active Record attributes, which come from the table: these know their type, take defaults and
cast, with no table at all.
The first block of the program prints what form_with and the casts see:
# A form object built on plain Ruby classes and Active Model alone.
# ActiveModel::API gives naming, conversion, attribute assignment and
# validations, so form_with and the error helpers accept the object;
# ActiveModel::Attributes gives typed attributes with defaults; and
# ActiveModel::Attributes::Normalization (new in Rails 8.1) adds normalizes.
# Nothing here starts a server or opens a database.
require "active_model"
# A fee split into base, GST and total, kept in paise (100 paise make a rupee)
# so that every sum is a whole number. Data.define builds an immutable value.
FeeBreakdown = Data.define(:plan, :base_paise, :gst_paise, :total_paise) do
def rupees(paise) = format("Rs %.2f", paise / 100.0)
end
# Kaksha's fee plans, and the 18 % GST it adds to each of them.
PLAN_PAISE = { "monthly" => 60_000, "term" => 150_000, "year" => 480_000 }.freeze
GST_PERCENT = 18
def fee_for(plan)
base = PLAN_PAISE.fetch(plan)
gst = base * GST_PERCENT / 100
FeeBreakdown.new(plan: plan, base_paise: base, gst_paise: gst, total_paise: base + gst)
end
class Student
include ActiveModel::API
include ActiveModel::Attributes
attribute :name, :string
attribute :grade, :integer
validates :name, presence: true, length: { maximum: 80 }
validates :grade, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: 12 }
end
class Guardian
include ActiveModel::API
include ActiveModel::Attributes
include ActiveModel::Attributes::Normalization
attribute :name, :string
attribute :phone, :string
# Keep the digits, drop the 0 of an 11-digit number or the 91 of a 12-digit
# one, and write +91 before the ten digits left. Any other length stays as
# bare digits for the format rule to refuse. Running it twice changes nothing.
normalizes :phone, with: ->(value) do
digits = value.gsub(/\D/, "")
digits = digits.delete_prefix("0") if digits.length == 11
digits = digits.delete_prefix("91") if digits.length == 12
digits.length == 10 ? "+91#{digits}" : digits
end
validates :name, presence: true
validates :phone, format: { with: /\A\+91[6-9]\d{9}\z/, message: "must be an Indian mobile" }
end
# The form object: one screen that enrols a student with a guardian and a fee
# plan. It validates itself and both records, and copies their messages onto
# its own errors so that a view reads them in one place.
class EnrolmentForm
include ActiveModel::API
include ActiveModel::Attributes
attribute :student_name, :string
attribute :student_grade, :integer
attribute :guardian_name, :string
attribute :guardian_phone, :string
attribute :fee_plan, :string, default: "monthly"
validates :fee_plan, inclusion: { in: PLAN_PAISE.keys }
def student
@student ||= Student.new(name: student_name, grade: student_grade)
end
def guardian
# Assigning the phone runs the normalizer, so the Guardian holds the clean one.
@guardian ||= Guardian.new(name: guardian_name, phone: guardian_phone)
end
def fee
fee_for(fee_plan) if PLAN_PAISE.key?(fee_plan)
end
# True only when the form and both records are valid. Every message of a
# record is copied onto the form as student_name, guardian_phone and so on.
def validate_all
valid?
{ student: student, guardian: guardian }.each do |prefix, record|
next if record.valid?
record.errors.each { |err| errors.add(:"#{prefix}_#{err.attribute}", err.message) }
end
errors.empty?
end
end
# The runs below happen when this file is run, not when a test requires it.
if __FILE__ == $PROGRAM_NAME
def show(label, form)
puts "== #{label} =="
ok = form.validate_all
puts " stored guardian.phone -> #{form.guardian.phone.inspect}"
puts " valid? -> #{ok}"
if ok
f = form.fee
puts " fee for #{f.plan}: base #{f.rupees(f.base_paise)}, GST #{f.rupees(f.gst_paise)}, total #{f.rupees(f.total_paise)}"
else
form.errors.full_messages.each { |m| puts " - #{m}" }
end
puts
end
puts "== What form_with and the casts see =="
blank = EnrolmentForm.new(student_grade: "10")
puts " model_name.param_key -> #{blank.model_name.param_key.inspect}, persisted? -> #{blank.persisted?}"
puts " student_grade posted as \"10\" -> #{blank.student_grade.inspect}, as \"\" -> #{EnrolmentForm.new(student_grade: "").student_grade.inspect}"
puts " fee_plan when nothing is posted -> #{blank.fee_plan.inspect}"
puts
puts "== One mobile typed four ways, then a landline =="
["+91 98765-43210", "098765 43210", "919876543210", "9876543210", "011 2345 6789"].each do |typed|
g = Guardian.new(name: "Sita Kumar", phone: typed)
puts format(" %-17s -> %-16s valid? %s", typed.inspect, g.phone.inspect, g.valid?)
end
once = Guardian.normalize_value_for(:phone, "098765 43210")
puts " normalized once -> #{once.inspect}, twice -> #{Guardian.normalize_value_for(:phone, once).inspect}"
puts
show("A good enrolment",
EnrolmentForm.new(student_name: "Ravi Kumar", student_grade: "10",
guardian_name: "Sita Kumar", guardian_phone: "+91 98765-43210", fee_plan: "term"))
show("Bad input in every field",
EnrolmentForm.new(student_name: "", student_grade: "15",
guardian_name: "", guardian_phone: "011 2345 6789", fee_plan: "quarterly"))
puts "== A fee value that cannot change =="
fee = fee_for("year")
puts " year plan: #{fee.rupees(fee.base_paise)} + #{fee.rupees(fee.gst_paise)} GST = #{fee.rupees(fee.total_paise)}"
puts " frozen? -> #{fee.frozen?}, a total_paise= writer? -> #{fee.respond_to?(:total_paise=)}"
copy = fee.with(total_paise: 0)
puts " with(total_paise: 0) built a new value (#{copy.rupees(copy.total_paise)}); the first still says #{fee.rupees(fee.total_paise)}"
begin
fee.instance_variable_set(:@note, "discount")
rescue FrozenError => e
puts " adding state raised FrozenError: #{e.message}"
end
end Output
== What form_with and the casts see == model_name.param_key -> "enrolment_form", persisted? -> false student_grade posted as "10" -> 10, as "" -> nil fee_plan when nothing is posted -> "monthly" == One mobile typed four ways, then a landline == "+91 98765-43210" -> "+919876543210" valid? true "098765 43210" -> "+919876543210" valid? true "919876543210" -> "+919876543210" valid? true "9876543210" -> "+919876543210" valid? true "011 2345 6789" -> "+911123456789" valid? false normalized once -> "+919876543210", twice -> "+919876543210" == A good enrolment == stored guardian.phone -> "+919876543210" valid? -> true fee for term: base Rs 1500.00, GST Rs 270.00, total Rs 1770.00 == Bad input in every field == stored guardian.phone -> "+911123456789" valid? -> false - Fee plan is not included in the list - Student name can't be blank - Student grade must be less than or equal to 12 - Guardian name can't be blank - Guardian phone must be an Indian mobile == A fee value that cannot change == year plan: Rs 4800.00 + Rs 864.00 GST = Rs 5664.00 frozen? -> true, a total_paise= writer? -> false with(total_paise: 0) built a new value (Rs 0.00); the first still says Rs 5664.00 adding state raised FrozenError: can't modify frozen FeeBreakdown: #<data FeeBreakdown plan="year", base_paise=480000, gst_paise=86400, total_paise=566400>
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby enrolment_form.rb
The form’s param key is "enrolment_form", so its fields are named enrolment_form[student_name] and so on, and
persisted? is false, so form_with treats it as a new record to create. A posted "10" became the integer 10,
a posted "" became nil, and fee_plan came back as its default, "monthly". The good enrolment is valid, with a
term fee of Rs 1500.00 plus Rs 270.00 of GST, Rs 1770.00 in all. The bad one shows a form-level error on
fee_plan and the messages of both inner records, copied onto the form as Student name can't be blank and the like.
Key idea
A class that includes ActiveModel::API and ActiveModel::Attributes looks to Action View like an Active Record
row: form_with, the type casts and errors all work on it without a table behind it.
Checkpoint
Check: ActiveModel::API and typed attributes
normalizes cleans input before it is validated
A mobile number reaches the server in many shapes: with +91, with a leading 0, with spaces and hyphens.
include ActiveModel::Attributes::Normalization adds the normalizes macro, and Guardian declares one block that
turns all of those shapes into "+919876543210". The
API documentation
says a normalization is applied when the attribute is assigned or validated, skips nil unless apply_to_nil: is
set, and should be
Giving the same result when it is applied twice as when it is applied once: cleaning an already clean value changes nothing.,
because it may run more than once. Step through one phone:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A form posts the phone. The browser sends the field as the user typed it: "+91 98765-43210", with a country code, a space and a hyphen. The form object assigns it to guardian.phone.
- The normalizes block runs. Assigning the value runs the lambda: it keeps only the digits, drops the 0 of an eleven-digit number or the 91 of a twelve-digit one, and writes "+91" before the ten digits left.
- The object holds one shape. guardian.phone is now "+919876543210". The run assigned "+91 98765-43210", "098765 43210", "919876543210" and "9876543210" in turn, and stored this same string every time.
- Cleaning twice changes nothing. normalize_value_for ran the block on "098765 43210" and then on its own result: both gave "+919876543210". The documentation asks for exactly this, because the block may run more than once.
- The validator reads the result. The format rule tests the stored string against /\A\+91[6-9]\d{9}\z/: +91 and then ten digits, the first of them 6 to 9. The cleaned mobile matches, so valid? returns true.
- A number that is not a mobile. For "011 2345 6789", a landline, the same block stored "+911123456789" and the format rule refused it, because 1 follows +91. Normalizing shaped the value; validation decided whether to accept it.
The program’s second block assigned the mobile four ways and stored "+919876543210" every time, and
normalize_value_for gave the same string back when it cleaned its own result. The landline "011 2345 6789" was
cleaned too, into "+911123456789", and then refused by the format rule, which wants a digit from 6 to 9 right
after +91. The two jobs stay apart: normalizes decides the one shape a value is kept in, and the validation decides
whether that shape is acceptable.
New in Rails 8.1
normalizes used to belong to Active Record alone. Rails 8.1 moved it into Active Model as
ActiveModel::Attributes::Normalization (see the
Active Model changelog), and Active Record
models now include that module, so a plain class and a model declare a normalization the same way.
Checkpoint
Check: normalizes, then validation
A form object owns a screen that writes several models
An enrolment is one business act, so EnrolmentForm is one class: it holds the screen’s fields, checks them and
saves the three records together. The controller keeps a single decision, to redirect when @form.save answers
true and to render the form again when it answers false. Its save does three things, in this order:
- It calls
validate_all:valid?on the form and on each record, with every record’s messages copied onto the form, so the view reads oneerrorsobject. If anything fails, it answersfalsebefore a statement is sent. - It opens
ActiveRecord::Base.transactionand writes each record withsave!, which raises when a write fails. The Transactions documentation describes such a block as statements that are “permanent if they can all succeed as one atomic action”. - It rescues the exception outside the block, after the
ROLLBACK, adds a message toerrorsand answersfalse. The same documentation warns against catching a database error, such as a refused unique value, inside the block.
The second program puts the form in front of three tables of an in-memory SQLite database, with a unique index: An index that refuses a second row with the same value, or the same combination of values, in its columns, so the database itself keeps repeats out. More in “Migrations and the database schema” on the guardian’s phone, and prints every statement that changes data:
# The enrolment form object again, now in front of three Active Record tables
# in an in-memory SQLite database that lives only for this run. save checks
# the form and every record first, then writes all three inside one
# transaction, so a write the database refuses also undoes the writes before
# it. A subscriber prints every statement that changes the database.
require "active_record"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
create_table :students do |t|
t.string :name, null: false
t.integer :grade, null: false
end
create_table :guardians do |t|
t.references :student, null: false
t.string :name, null: false
t.string :phone, null: false, index: { unique: true } # one guardian per mobile
end
create_table :fee_plans do |t|
t.references :student, null: false
t.string :plan, null: false
t.integer :total_paise, null: false
end
end
# Print each write with the values it sends: INSERT INTO guardians name="..."
ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
sql = payload[:sql]
next if payload[:name] == "SCHEMA" || sql.start_with?("SELECT", "PRAGMA")
if (insert = sql.match(/\AINSERT INTO "(\w+)" \(([^)]*)\)/))
binds = payload[:type_casted_binds]
binds = binds.call if binds.respond_to?(:call)
pairs = insert[2].delete('"').split(", ").zip(binds).map { |col, val| "#{col}=#{val.inspect}" }
puts " SQL: INSERT INTO #{insert[1]} #{pairs.join(", ")}"
else
puts " SQL: #{sql}"
end
end
PLAN_PAISE = { "monthly" => 60_000, "term" => 150_000, "year" => 480_000 }.freeze
class Student < ActiveRecord::Base
validates :name, presence: true
validates :grade, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: 12 }
end
class Guardian < ActiveRecord::Base
belongs_to :student
normalizes :phone, with: ->(value) do
digits = value.gsub(/\D/, "")
digits = digits.delete_prefix("0") if digits.length == 11
digits = digits.delete_prefix("91") if digits.length == 12
digits.length == 10 ? "+91#{digits}" : digits
end
validates :name, presence: true
validates :phone, format: { with: /\A\+91[6-9]\d{9}\z/, message: "must be an Indian mobile" }
end
class FeePlan < ActiveRecord::Base
belongs_to :student
end
class EnrolmentForm
include ActiveModel::API
include ActiveModel::Attributes
attribute :student_name, :string
attribute :student_grade, :integer
attribute :guardian_name, :string
attribute :guardian_phone, :string
attribute :fee_plan, :string, default: "monthly"
validates :fee_plan, inclusion: { in: PLAN_PAISE.keys }
def student = @student ||= Student.new(name: student_name, grade: student_grade)
def guardian = @guardian ||= Guardian.new(student: student, name: guardian_name, phone: guardian_phone)
def plan
base = PLAN_PAISE.fetch(fee_plan, 0)
@plan ||= FeePlan.new(student: student, plan: fee_plan, total_paise: base + base * 18 / 100)
end
# 1. Check the form and every record, and copy their messages onto the form.
# 2. Write all three in one transaction; save! raises if a write is refused.
# 3. Rescue outside the block, after the ROLLBACK, and answer false.
def save
return false unless validate_all
ActiveRecord::Base.transaction do
student.save!
guardian.save!
plan.save!
end
true
rescue ActiveRecord::RecordNotUnique
errors.add(:guardian_phone, :taken)
false
rescue ActiveRecord::RecordInvalid => e
errors.add(:base, e.message)
false
end
def validate_all
valid?
{ student: student, guardian: guardian }.each do |prefix, record|
next if record.valid?
record.errors.each { |err| errors.add(:"#{prefix}_#{err.attribute}", err.message) }
end
errors.empty?
end
end
def enrol(label, attrs)
puts "== #{label} =="
form = EnrolmentForm.new(attrs)
ok = form.save
puts " save -> #{ok}"
form.errors.full_messages.each { |m| puts " - #{m}" }
puts " rows: students #{Student.count}, guardians #{Guardian.count}, fee_plans #{FeePlan.count}"
puts " student still new? #{form.student.new_record?}" unless ok
puts
end
enrol("Ravi, term plan",
student_name: "Ravi Kumar", student_grade: "10",
guardian_name: "Sita Kumar", guardian_phone: "+91 98765-43210", fee_plan: "term")
enrol("Kiran, same guardian mobile typed another way",
student_name: "Kiran Kumar", student_grade: "7",
guardian_name: "Sita Kumar", guardian_phone: "098765 43210", fee_plan: "year")
enrol("A form with a blank name and a grade of 15",
student_name: "", student_grade: "15",
guardian_name: "Meera N", guardian_phone: "9876501234", fee_plan: "term") Output
== Ravi, term plan == SQL: BEGIN immediate TRANSACTION SQL: INSERT INTO students name="Ravi Kumar", grade=10 SQL: INSERT INTO guardians student_id=1, name="Sita Kumar", phone="+919876543210" SQL: INSERT INTO fee_plans student_id=1, plan="term", total_paise=177000 SQL: COMMIT TRANSACTION save -> true rows: students 1, guardians 1, fee_plans 1 == Kiran, same guardian mobile typed another way == SQL: BEGIN immediate TRANSACTION SQL: INSERT INTO students name="Kiran Kumar", grade=7 SQL: INSERT INTO guardians student_id=2, name="Sita Kumar", phone="+919876543210" SQL: ROLLBACK TRANSACTION save -> false - Guardian phone has already been taken rows: students 1, guardians 1, fee_plans 1 student still new? true == A form with a blank name and a grade of 15 == save -> false - Student name can't be blank - Student grade must be less than or equal to 12 rows: students 1, guardians 1, fee_plans 1 student still new? true
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby enrolment_save.rb
Ravi’s enrolment sent BEGIN, three INSERTs and COMMIT, and each table held one row. Kiran’s form passed every
rule, but his guardian’s mobile, typed as "098765 43210", was cleaned into the "+919876543210" that Ravi’s
guardian already holds. The index refused that INSERT, the transaction sent ROLLBACK, and the student row
inserted a moment earlier was undone: the counts stayed at 1, 1 and 1, and the student is a new record again. The
third form never reached the database, because its two messages came from the checks. Step through the same saves:
The drawing shows the whole scene; its 10 steps are listed below.
Every step as text
- The controller asks once. The create action builds EnrolmentForm.new from the posted fields and makes one decision: when @form.save answers true it redirects, and when it answers false it renders the form again.
- The form checks before writing. save calls validate_all: valid? on the form (the fee plan must be a known one), then valid? on the student and the guardian, whose messages are copied onto the form. Ravi's form meets every rule.
- One transaction opens. save opens ActiveRecord::Base.transaction and SQLite logs BEGIN. Nothing written inside the block is kept unless the block ends without an exception.
- The student row is written. student.save! sends INSERT INTO students for Ravi Kumar, grade 10. The bang form raises when a write fails instead of answering false, so a failure cannot slip by.
- The guardian row is written. guardian.save! sends INSERT INTO guardians with the phone "+919876543210", the cleaned form of what was typed.
- The fee plan row is written. plan.save! sends INSERT INTO fee_plans with the term plan and its total of 177000 paise, Rs 1770.00 with GST.
- COMMIT keeps all three. No write raised, so the block ends and the transaction commits. save answers true and the counts read students 1, guardians 1, fee_plans 1.
- Kiran reuses the same mobile. Kiran's form meets every rule, so the same path runs: BEGIN, INSERT INTO students, then INSERT INTO guardians with "+919876543210" again, typed as "098765 43210". The unique index on phone refuses it and Active Record raises RecordNotUnique.
- ROLLBACK undoes Kiran's student. The exception leaves the block, so the transaction sends ROLLBACK and the student row it had inserted is gone: the counts stay at 1, 1 and 1. The rescue in save, outside the block, adds "has already been taken" to guardian_phone and answers false.
- A bad form never reaches SQL. The third form has a blank name and a grade of 15. validate_all copies the two messages onto the form and save answers false before any statement, BEGIN included, is sent.
Letting save raise instead of answering false
A save that lets the exception escape gives the controller nothing to render. Rails’ default rescue_responses
(Configuring Rails) answer an escaped
ActiveRecord::RecordInvalid with a 422 error page, and a RecordNotUnique, which the list does not name, with a
500, so the user sees an error page instead of the form and its messages. Rescue in the form, add the message to the
attribute it belongs to, or to :base, and answer false.
The fee belongs to the form too, as a value worked out from the plan, and a value worked out for a screen should not
change on its way to the view. FeeBreakdown is a Ruby Data class, which the
Ruby documentation describes as storage for immutable values with no
member writers. The first program’s last block shows it: the year plan’s Rs 5664.00 came with frozen? true and
no total_paise= writer, with(total_paise: 0) built a second value and left the first at Rs 5664.00, and
adding state raised FrozenError. The amounts are whole numbers of paise, so base plus GST is one exact integer
sum, and rupees appear only when a value is printed. For one parent and its own children, the
nested forms lesson shows accepts_nested_attributes_for,
which saves them with the parent and needs no separate class.
Key idea
A form object’s save checks every rule first, writes each record with save! inside one transaction, and turns
a refused write into a message and false once the rollback has undone the rows before it.
Checkpoint
Check: A form object's transactional save
A plain object can answer a markdown request
Rails 8.1 added a markdown: renderer: render markdown: @notice calls to_markdown on the object and sends the
result as text/markdown. The release notes present it
as a way to “respond to markdown requests and render them directly”, and the
Action Pack changelog adds .md and
.markdown as Markdown extensions, so a new app knows the :md format with no initializer. The object needs no table
and no template: Kaksha’s EnrolmentNotice is a plain Active Model class with a to_markdown method, and the action
lists one format, format.md { render markdown: @notice }. Step through two requests, one for markdown and one for
JSON:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- curl asks for markdown. curl sends GET /notices/1 with Accept: text/markdown, and Rails routes it to NoticesController#show. The recorded session runs the server on port 3017.
- show builds a plain object. show builds EnrolmentNotice.new with the student, the guardian and the fee of Rs 1770. No row is read: EnrolmentNotice is a plain Active Model class with a to_markdown method.
- respond_to picks format.md. respond_to compares the Accept header with the formats its block lists. The block lists only format.md, and text/markdown is the :md type that Rails 8.1 registers on its own, so that branch runs.
- render markdown: @notice. render markdown: @notice hands the object to the markdown renderer, which calls @notice.to_markdown and sets the content type to text/markdown. No view template is involved.
- 200 with the note. The answer is status 200, Content-Type text/markdown; charset=utf-8, 112 bytes: the four-line note. The server log reads "Processing by NoticesController#show as MD".
- A request for JSON. A second curl asks for the same address with Accept: application/json. The log reads "as JSON", and the block has no branch for that type.
- 406 Not Acceptable. respond_to raises ActionController::UnknownFormat, which Rails answers with 406 Not Acceptable. The run printed status=406, and nothing was rendered.
The session below starts the app, sends both requests with curl and then prints what the server’s own terminal showed:
# The Rails 8.1 markdown response, from a plain Active Model object. The
# NoticesController lists one format, format.md { render markdown: @notice },
# which calls to_markdown on the plain EnrolmentNotice and sends the result
# as text/markdown. Rails 8.1 registers text/markdown as :md itself, so the
# app needs no initializer. A request for JSON finds no branch: 406.
require_relative "session"
Session.serve(
setup: <<~SH,
cd ../kaksha
bin/rails db:prepare 2>&1 | tail -n 2
SH
server: "bin/rails server -p 3017 -e development",
client: <<~SH,
curl -s -o /dev/null -w "status=%{http_code} content-type=%{content_type} bytes=%{size_download}\\n" -H "Accept: text/markdown" http://localhost:3017/notices/1
echo "---response body---"
curl -s -H "Accept: text/markdown" http://localhost:3017/notices/1
echo "---end body---"
curl -s -o /dev/null -w "status=%{http_code}\\n" -H "Accept: application/json" http://localhost:3017/notices/1
SH
port: 3017,
folder: "../kaksha",
) Output
$ cd ../kaksha
$ bin/rails db:prepare 2>&1 | tail -n 2
$ bin/rails server -p 3017 -e development
# The server keeps that terminal. In a second terminal:
$ curl -s -o /dev/null -w "status=%{http_code} content-type=%{content_type} bytes=%{size_download}\n" -H "Accept: text/markdown" http://localhost:3017/notices/1
status=200 content-type=text/markdown; charset=utf-8 bytes=112
$ echo "---response body---"
---response body---
$ curl -s -H "Accept: text/markdown" http://localhost:3017/notices/1
# Enrolment confirmed
Dear Sita Kumar,
Ravi Kumar is enrolled in the term. The fee due is Rs 1770.
-- Kaksha
$ echo "---end body---"
---end body---
$ curl -s -o /dev/null -w "status=%{http_code}\n" -H "Accept: application/json" http://localhost:3017/notices/1
status=406
# Ctrl+C in the first terminal stops the server. That terminal showed:
=> Booting Puma
=> Rails 8.1.4 application starting in development
=> Run `bin/rails server --help` for more startup options
Puma starting in single mode...
* Puma version: 8.0.2 ("Into the Arena")
* Ruby version: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [arm64-darwin23]
* Min threads: 3
* Max threads: 3
* Environment: development
* PID: …
* Listening on http://127.0.0.1:3017
* Listening on http://[::1]:3017
Use Ctrl-C to stop
Started GET "/notices/1" for ::1 at …
(…ms) CREATE TABLE "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY) /*application='Kaksha'*/
(…ms) CREATE TABLE "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL) /*application='Kaksha'*/
ActiveRecord::SchemaMigration Load (…ms) SELECT "schema_migrations"."version" FROM "schema_migrations" ORDER BY "schema_migrations"."version" ASC /*application='Kaksha'*/
Processing by NoticesController#show as MD
Parameters: {"id" => "1"}
Completed 200 OK in …ms (Views: …ms | ActiveRecord: …ms (0 queries, 0 cached) | GC: …ms)
Started GET "/notices/1" for ::1 at …
Processing by NoticesController#show as MD
Parameters: {"id" => "1"}
Completed 200 OK in …ms (Views: …ms | ActiveRecord: …ms (0 queries, 0 cached) | GC: …ms)
Started GET "/notices/1" for ::1 at …
Processing by NoticesController#show as JSON
Parameters: {"id" => "1"}
Completed 406 Not Acceptable in …ms (ActiveRecord: …ms (0 queries, 0 cached) | GC: …ms)
ActionController::UnknownFormat (ActionController::UnknownFormat):
app/controllers/notices_controller.rb:11:in 'NoticesController#show'
- Gracefully stopping, waiting for requests to finish
=== puma shutdown: … ===
- Goodbye!
Exiting
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby markdown_notice_session.rb
The markdown request came back status=200, text/markdown; charset=utf-8 and 112 bytes, the four-line note that
to_markdown built, and the log reads Processing by NoticesController#show as MD. The JSON request reached the same
action, found no branch for its type and got status=406: respond_to raised ActionController::UnknownFormat,
which the default rescue_responses turn into 406 Not Acceptable. A client that should be served needs a branch of
its own, such as the format.html that the release notes’ example lists beside format.md.
Checkpoint
Check: Markdown responses from a plain object
A form object is tested without a server or a database
Because the form object is plain Ruby, a test builds one from a hash, calls validate_all and reads errors: no
Rails app boots, no request is made and no database opens. These six tests pin down the rules of the first program,
and the last of them checks that neither Active Record nor Action Dispatch was ever loaded:
# Unit tests for the enrolment form object. They load Minitest, Active Model
# (with the Active Support it needs) and the form's own file: no Rails app,
# no server, no browser and no database. Run them with: ruby enrolment_form_test.rb
require "minitest/autorun"
require_relative "enrolment_form"
class EnrolmentFormTest < Minitest::Test
def form(**changes)
EnrolmentForm.new({ student_name: "Ravi Kumar", student_grade: "10", guardian_name: "Sita Kumar",
guardian_phone: "98765 43210", fee_plan: "term" }.merge(changes))
end
def test_a_good_enrolment_is_valid
assert form.validate_all
end
def test_the_phone_is_kept_in_one_shape
assert_equal "+919876543210", form.guardian.phone
end
def test_a_landline_is_cleaned_then_refused
f = form(guardian_phone: "011 2345 6789")
refute f.validate_all
assert_equal ["must be an Indian mobile"], f.errors[:guardian_phone]
end
def test_the_student_messages_are_copied_onto_the_form
f = form(student_name: "", student_grade: "15")
refute f.validate_all
assert_equal ["Student name can't be blank", "Student grade must be less than or equal to 12"], f.errors.full_messages
end
def test_the_term_fee_is_derived_and_frozen
fee = form.fee
assert_equal 177_000, fee.total_paise
assert fee.frozen?
end
def test_nothing_else_of_rails_was_loaded
refute defined?(ActiveRecord), "Active Record was loaded"
refute defined?(ActionDispatch), "Action Dispatch was loaded"
end
end Run in a terminal, they load Minitest, Active Model with the Active Support it needs, and the form’s own file:
# Runs the form object's unit tests the way you would in a terminal. The tests
# need Ruby, Minitest and Active Model, and no app, server, browser or database.
require_relative "session"
Session.run(<<~SH)
cd ..
ruby enrolment_form_test.rb
SH Output
$ cd .. $ ruby enrolment_form_test.rb Run options: --seed … # Running: ...... Finished in …s, … runs/s, … assertions/s. 6 runs, 10 assertions, 0 failures, 0 errors, 0 skips
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby form_test_session.rb
All 6 tests and their 10 assertions passed; the timings show as … because they change on every run. The other test
kinds of Rails, as the testing guide describes them, cover what a
unit test cannot see:
| Test | What it starts | What it pins down |
|---|---|---|
| Form-object unit test | Ruby, Active Model and the form | validations, normalizes, derived values, copied messages |
Controller test (ActionDispatch::IntegrationTest) |
the Rails app and its test database | the action’s status code and redirect |
| System test (Capybara) | the app, its test database and a browser, Chrome driven by Selenium by default | what the user sees, JavaScript included |
Keep each rule in the cheapest test that can see it: the unit test owns the validations and the shapes, the
controller test owns the status and the redirect, and the system test owns what the user sees. A rollback is the
exception, because it happens inside the database: the second program, which runs against a real one, is what
shows it, and a stubbed save! in a unit test would hide it.
Checkpoint
Check: Testing a form object in plain Ruby
Practise: change the programs and predict the output
The programs need Ruby 4.0 with the Rails 8.1.4 gems, sqlite3 included for the second one. Copy each into a file,
run it with ruby, and write down what you expect before each run:
- In the first program’s good enrolment, change
fee_planfrom"term"to"year". Expectfee for year: base Rs 4800.00, GST Rs 864.00, total Rs 5664.00, the total the fee block printed. - Make only the guardian’s phone wrong there, with
"12345". Expectvalid? -> falseand one message,Guardian phone must be an Indian mobile. - Delete the
normalizes :phonedeclaration fromGuardian. Expect the four ways of typing the mobile to be stored as typed and refused, and the six tests to end with 3 failures. - In the second program, give Kiran’s guardian another mobile,
"9876501234". Expect threeINSERTs and aCOMMITfor him too,save -> true, and two rows in every table.
Then check what stayed with you; the questions start with the idea you have answered weakest so far:
Practice that adapts to you
Practise this lesson
11 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): A form object’s save returns false, yet the database holds one of the three rows it was
supposed to write. Why is that a bug, and how does ActiveRecord::Base.transaction remove it?
The form promises all or nothing: an enrolment either exists with its student, guardian and fee plan, or does not
exist at all. A student row without its guardian is a half-made enrolment that no screen is built to show, and the
next attempt can trip over it. The fix is to write every record with save! inside ActiveRecord::Base.transaction:
a refused write raises, the block sends ROLLBACK, and every row written before it is undone. In the second program,
Kiran’s student row was inserted, the guardian’s was refused by the unique index, and after the rollback each table
still held one row. The form rescues the exception outside the block, adds a message to errors and returns
false, so the controller renders the form again as it would for any invalid model.
Two harder questions about the same screen ask you to defend this lesson’s design choices against the alternatives:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to senior
A Kaksha enrolment screen posts student, guardian and fee-plan fields plus a consent checkbox the student table does not hold. The team wants to know whether the right home for the rules is a form object, extra columns on Student, a service object, or the controller. Pick one and defend it against the other three.
-
Fresher to staff
The lesson's first program builds Guardian on ActiveModel::API and ActiveModel::Attributes, while the Guardian behind its saving form is an Active Record model. A teammate asks which is right. Explain the trade: what each stack supplies, when the plain Active Model class is right, and when it is the wrong choice.
Flashcards · 5 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does include ActiveModel::API give a plain Ruby class?
Naming, conversion, attribute assignment and validations, so form_with and the error helpers accept it with no table behind it. ActiveModel::Attributes adds typed fields with defaults.
-
What does normalizes do in an Active Model class, and what must hold for its block?
Since Rails 8.1 it cleans a value into one shape as it is assigned, and a separate validation decides whether that shape is acceptable. Running the block a second time on its own output must change nothing.
-
How should a form object's save write the three records one screen creates?
Check every rule first, then write each record with save! inside one transaction. A refused write raises, the rollback removes the rows written before it, and save returns false with a message.
-
What does render markdown: with a plain object send, and what does a request for an unlisted type get?
Whatever the object's to_markdown returns, as text/markdown; Rails 8.1 knows the md format without an initializer. A request for a type the action does not list gets 406.
-
Where are a form object's rules tested, and where is its rollback shown?
The rules in plain Ruby, with no server, browser or database. The rollback happens in the database, so it is shown against a real one.
Key takeaways
include ActiveModel::APIgives a plain class naming, conversion, attribute assignment and validations, soform_withand the error helpers accept it, andActiveModel::Attributesadds typed fields with defaults.normalizes, which Rails 8.1 brought to Active Model, cleans a value into one shape when it is assigned, and a separate validation then decides whether that shape is acceptable; running the block twice must change nothing.- A form object’s
savechecks the form and its records first, writes each record withsave!inside one transaction, and turns a refused write into a message andfalseafter the rollback. render markdown:sends what an object’sto_markdownreturns astext/markdown, Rails 8.1 knows the:mdformat without an initializer, and a request for a type the action does not list gets 406.- A form object’s rules are tested in plain Ruby with no server, browser or database, while the rollback, which happens in the database, is shown against a real one.
Terms in this lesson
- Active Model
- The Rails library of model behaviour a plain Ruby class can mix in: attribute assignment, naming, conversion, translation, validations and typed attributes, which Rails 8.1 can also normalize. It never touches a database.
- form object
- A plain Ruby class that owns one screen's fields and rules: it validates across models, saves the records a successful submit needs, and leaves the controller with a single call to save.
References
- Active Model Basics (Ruby on Rails)
- ActiveModel::API (Ruby on Rails)
- ActiveModel::Model (Ruby on Rails)
- ActiveModel::Attributes (Ruby on Rails)
- ActiveModel::Attributes::Normalization::ClassMethods (Ruby on Rails)
- Active Model changelog, Rails 8.1.4 (Ruby on Rails)
- ActiveRecord::Transactions::ClassMethods (Ruby on Rails)
- Configuring Rails Applications (rescue_responses) (Ruby on Rails)
- Ruby on Rails 8.1 release notes (Ruby on Rails)
- Action Pack changelog, Rails 8.1.4 (Ruby on Rails)
- Testing Rails Applications (Ruby on Rails)
- Ruby class Data (Ruby 4.0) (Ruby core team)
Report a problem with this lesson
Kept only in this browser. Your Learn progress