Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Ruby 4.0.7 and rails 8.1.4
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Interactive · step by step What ActiveModel::API and Attributes add to a plain class Step through a plain Ruby class as it gains what form_with, the type casts and the error helpers need.
What ActiveModel::API and Attributes add to a plain class, step 5 of 5. Showing Plain Ruby class, ActiveModel::API (model_name, errors), Attributes ("10" becomes 10), EnrolmentForm (cast, check, report). In focus: EnrolmentForm.includeincludePlain Ruby classattr_accessor onlyActiveModel::APImodel_name, errorsAttributes"10" becomes 10EnrolmentFormcast, check, reporttyped values

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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 on Active Model alone: casts, a cleaned phone, two enrolments and a frozen fee Ruby · enrolment_form.rb
# 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

  1. Question 1 of 2 What does include ActiveModel::API give a plain Ruby class, so that form_with and the error helpers accept it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Attribute assignment, conversion, naming, translation and validations

    ActiveModel::API bundles attribute assignment, conversion, naming, translation and validations, the shape Action View expects from a model. Typed attributes come from a second module, ActiveModel::Attributes, and neither one touches a database.

  2. Question 2 of 2 EnrolmentForm declares attribute :student_grade, :integer. A browser posts student_grade as "10". What does form.student_grade return right after EnrolmentForm.new?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The integer 10

    Typed attributes turn what a form posts into the declared type the moment it is assigned: "10" becomes 10, "" becomes nil, and a field that is not posted takes its default, as fee_plan took "monthly".

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:

Interactive · stage by stage One mobile typed four ways, one stored value Step through a phone being assigned to a Guardian: the normalizer runs first, then the format rule reads what it stored.
One mobile typed four ways, one stored value, step 6 of 6. Showing Typed by the user, normalizes runs (same result twice), Stored on the object, Format rule reads it, valid? decides (not a mobile). In focus: valid? decides.not a mobileTyped by the user"+91 98765-43210"normalizes runssame result twiceStored on the object"+919876543210"Format rule reads it+91, then 6 to 9valid? decidesnot a mobilerefused

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 In Rails 8.1, which module gives a plain Active Model class the normalizes macro, and when does a normalization run?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: ActiveModel::Attributes::Normalization; when the attribute is assigned or validated

    include ActiveModel::Attributes::Normalization adds normalizes to a class with typed attributes. The block runs when the value is assigned, and again when it is validated, so every rule sees the cleaned value.

  2. Question 2 of 2 A parent types the mobile as "098765 43210". Guardian declares the lesson's normalizes block. What does guardian.phone return right after Guardian.new?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: "+919876543210"

    normalizes cleans on assignment: "098765 43210" becomes "+919876543210" before any rule reads it, and the format rule then accepts it.

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:

  1. 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 one errors object. If anything fails, it answers false before a statement is sent.
  2. It opens ActiveRecord::Base.transaction and writes each record with save!, 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”.
  3. It rescues the exception outside the block, after the ROLLBACK, adds a message to errors and answers false. 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 same form saving three tables in one transaction: a COMMIT, a ROLLBACK and no SQL at all Ruby · enrolment_save.rb
# 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:

Interactive · stage by stage One form object, three records, one transaction Step through EnrolmentForm#save for three enrolments: it checks the form and its records, writes all three in one transaction, and rolls back when the database refuses a write.
One form object, three records, one transaction, step 10 of 10. Showing Controller action, EnrolmentForm#save (two messages), Student#save!, Guardian#save! (INSERT guardians), FeePlan#save!, Transaction ends (ROLLBACK: none kept). In focus: EnrolmentForm#save.raisesController actioncalls @form.saveEnrolmentForm#savetwo messagesStudent#save!INSERT studentsGuardian#save!INSERT guardiansFeePlan#save!INSERT fee_plansTransaction endsROLLBACK: none kept

The drawing shows the whole scene; its 10 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. The guardian row is written. guardian.save! sends INSERT INTO guardians with the phone "+919876543210", the cleaned form of what was typed.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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

  1. Question 1 of 2 In the enrolment, which job belongs to EnrolmentForm rather than to the controller or to one of the models?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Checking the form and its three records together and writing them in one transaction

    A form object owns one screen's rules and its all-or-nothing save. The controller keeps the redirect-or-render decision, the view renders, and the schema keeps the indexes that guard the data.

  2. Question 2 of 2 In the second program, Kiran's student row was inserted before his guardian's INSERT was refused. How many rows did the students table hold after his save?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 1, Ravi's: the ROLLBACK undid Kiran's student row

    The program printed students 1, guardians 1, fee_plans 1 after Kiran's save: the exception that left the block rolled back his student row, and Ravi's committed rows were untouched.

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:

Interactive · step by step A text/markdown request handled by format.md Step through two requests from curl, one asking for text/markdown and one for JSON, to an action that lists only format.md.
A text/markdown request handled by format.md, step 7 of 7. Showing curl, NoticesController (no JSON branch), EnrolmentNotice (four-line note). In focus: curl.curlthe clientNoticesControllerno JSON branchEnrolmentNoticefour-line note406

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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".
  6. 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.
  7. 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:

Two requests to a format.md action: text/markdown gets 200, JSON gets 406 Ruby · sessions/markdown_notice_session.rb
# 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

  1. Question 1 of 2 An action writes format.md { render markdown: @notice }. What builds the body that Rails sends?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The to_markdown method of @notice

    render markdown: @notice calls @notice.to_markdown and sends the string with Content-Type text/markdown. The object needs that one method, and no table or template.

  2. Question 2 of 2 The same action lists only format.md. A client asks for it with Accept: application/json. What does the client get, and why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 406 Not Acceptable, because respond_to has no branch for JSON

    respond_to compares the Accept header with the formats its block lists. A type it does not list raises ActionController::UnknownFormat, which Rails answers with 406 Not Acceptable.

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:

Six unit tests for EnrolmentForm Ruby · enrolment_form_test.rb
# 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:

The six tests, run in a terminal Ruby · sessions/form_test_session.rb
# 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

  1. One question The six unit tests of EnrolmentForm ran with ruby enrolment_form_test.rb. What did they load?

    Choose one answer.

    Show the answer: One question

    Answer: Minitest, Active Model with the Active Support it needs, and the form's own file

    A form object is plain Ruby, so its rules are tested by building one, calling validate_all and reading errors. That needs Minitest and Active Model, and none of the server, database or browser that other kinds of test start.

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:

  1. In the first program’s good enrolment, change fee_plan from "term" to "year". Expect fee for year: base Rs 4800.00, GST Rs 864.00, total Rs 5664.00, the total the fee block printed.
  2. Make only the guardian’s phone wrong there, with "12345". Expect valid? -> false and one message, Guardian phone must be an Indian mobile.
  3. Delete the normalizes :phone declaration from Guardian. Expect the four ways of typing the mobile to be stored as typed and refused, and the six tests to end with 3 failures.
  4. In the second program, give Kiran’s guardian another mobile, "9876501234". Expect three INSERTs and a COMMIT for 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.

  1. Question 1 of 11 A class SeatRequest includes ActiveModel::API and declares attr_accessor :grade, without ActiveModel::Attributes. What does SeatRequest.new(grade: "10").grade return?

    Choose one answer.

    Show the answer: Question 1 of 11

    Answer: The string "10"

    ActiveModel::API gives a class a new that assigns a hash of attributes, but it casts nothing: "10" stays a string until a typed attribute from ActiveModel::Attributes declares the field an integer.

  2. Question 2 of 11 A form object declares attribute :needs_bus, :boolean, default: false. Its check box posts "0" when it is left unticked and "1" when it is ticked. What do the two posts give?

    Choose one answer.

    Show the answer: Question 2 of 11

    Answer: false for "0" and true for "1"

    Typed attributes cast what a form posts into the declared type: for a boolean, "0" becomes false and "1" becomes true. The default is used only when nothing is posted, and a blank "" becomes nil.

  3. Question 3 of 11 A guardian's phone comes in as "011 2345 6789", a landline. Guardian has the lesson's normalizes block and the mobile format rule. What happens, in order?

    Choose one answer.

    Show the answer: Question 3 of 11

    Answer: normalizes stores "+911123456789" on assignment, then the format rule refuses it

    Normalizing shapes the value and validation decides whether to accept it: the landline is stored as "+911123456789" and then refused by the format rule.

  4. Question 4 of 11 An early draft of Guardian's block kept the digits, dropped a leading 0 and put +91 in front of whatever was left, whatever its length. It cleaned the short "12345" into "+9112345". What would a second pass of that draft give?

    Choose one answer.

    Show the answer: Question 4 of 11

    Answer: "+919112345": the draft is not idempotent

    The documentation asks for an idempotent normalization because it may run more than once. The draft adds +91 on every pass for a short number, while the lesson's block leaves any number that is not ten digits as bare digits, so a second pass changes nothing and the format rule refuses it.

  5. Question 5 of 11 Inside the transaction, student.save would answer false when a validation fails. Why does EnrolmentForm#save call save! instead?

    Choose one answer.

    Show the answer: Question 5 of 11

    Answer: save! raises, and only an exception that leaves the block makes the transaction roll back

    A transaction rolls back when an exception leaves its block. save! raises when a validation fails, so a failure undoes every earlier write; a false from save would let the block go on and commit.

  6. Question 6 of 11 A form's own rules all hold, but its student fails two rules and its guardian fails two. How many messages does form.errors.full_messages list after validate_all?

    Choose one answer.

    Show the answer: Question 6 of 11

    Answer: Four, each named after its record and field, such as "Student name can't be blank"

    validate_all copies each message of each record onto the form, so the view reads one errors object with four messages, each named after its record and field.

  7. Question 7 of 11 A teammate moves the rescue of RecordNotUnique inside the transaction block, so that the block itself ends normally after the guardian's INSERT is refused. What does the database keep of Kiran's enrolment?

    Choose one answer.

    Show the answer: Question 7 of 11

    Answer: His student row, because the block ended normally and the transaction committed

    The rescue belongs outside the block, after the ROLLBACK. Rescued inside, the transaction commits the rows written so far, and the Transactions documentation also warns against catching database errors inside the block, because some databases cannot go on with that transaction.

  8. Question 8 of 11 Does a new Rails 8.1.4 app need Mime::Type.register "text/markdown", :md in an initializer before format.md works?

    Choose one answer.

    Show the answer: Question 8 of 11

    Answer: No: Action Pack 8.1 registers text/markdown as :md, with the .md and .markdown extensions

    Rails 8.1 registers the Markdown type itself, so format.md and render markdown: work in a new app. An initializer is needed only for a type that Rails does not know.

  9. Question 9 of 11 A teammate removes respond_to and leaves only render markdown: @notice in show. A client asks with Accept: application/json. What does it get now?

    Choose one answer.

    Show the answer: Question 9 of 11

    Answer: 200 with the markdown, as text/markdown, because nothing compares the Accept header any more

    respond_to is what turns an unlisted type into 406. A bare render markdown: sends the to_markdown body as text/markdown whatever the client asked for, so keep respond_to when the Accept header should decide.

  10. Question 10 of 11 Which test should pin down that a landline is refused with the message "must be an Indian mobile"?

    Choose one answer.

    Show the answer: Question 10 of 11

    Answer: A form-object unit test that builds the form and reads errors

    Keep each rule in the cheapest test that can see it: validations and normalizations in the form object's unit test, the status and the redirect in a controller test, and what the user sees in a system test.

  11. Question 11 of 11 You want a test that proves EnrolmentForm#save rolls back Kiran's student row when his guardian's INSERT is refused. Can it be a plain unit test like the six?

    Choose one answer.

    Show the answer: Question 11 of 11

    Answer: No: the rollback happens in the database, so the test runs against a real one and checks the row counts

    A form object's rules belong in fast unit tests, but a rollback is the database's behaviour: prove it against a real database, as the second program does, and read the row counts.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 5 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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::API gives a plain class naming, conversion, attribute assignment and validations, so form_with and the error helpers accept it, and ActiveModel::Attributes adds 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 save checks the form and its records first, writes each record with save! inside one transaction, and turns a refused write into a message and false after the rollback.
  • render markdown: sends what an object’s to_markdown returns as text/markdown, Rails 8.1 knows the :md format 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.