Conventions
Dex::Form handles data holding, normalization, and validation. Persistence and record mapping are your responsibility – the form doesn't know (or care) how your data gets saved. These are the recommended patterns.
.for – loading from a record
Define a class method that maps record attributes to form attributes and binds the record:
class Employee::Form < Dex::Form
model Employee
attribute :name, :string
attribute :email, :string
def self.for(employee)
new(name: employee.name, email: employee.email).with_record(employee)
end
end@form = Employee::Form.for(@employee)with_record sets persisted? to true and gives the uniqueness validator a record to exclude.
Multi-model forms
This is where form objects really shine. When a single form spans multiple models, .for maps each model's data into the flat form structure:
class Employee::OnboardingForm < Dex::Form
attribute :first_name, :string
attribute :last_name, :string
attribute :email, :string
attribute :department, :string
attribute :position, :string
attribute :start_date, :date
normalizes :email, with: -> { _1&.strip&.downcase.presence }
validates :email, presence: true, uniqueness: { model: Employee }
validates :first_name, :last_name, :department, :position, presence: true
nested_one :address do
attribute :street, :string
attribute :city, :string
attribute :postal_code, :string
attribute :country, :string
validates :street, :city, :country, presence: true
end
nested_many :emergency_contacts do
attribute :name, :string
attribute :phone, :string
validates :name, :phone, presence: true
end
def self.for(employee)
new(
first_name: employee.first_name,
last_name: employee.last_name,
email: employee.email,
department: employee.department.name,
position: employee.position.title,
start_date: employee.start_date,
address: {
street: employee.address.street,
city: employee.address.city,
postal_code: employee.address.postal_code,
country: employee.address.country
},
emergency_contacts: employee.emergency_contacts.map { |c|
{ name: c.name, phone: c.phone }
}
).with_record(employee)
end
end#save – persisting with an Operation
The save method validates the form and delegates persistence to whatever makes sense for your app. The cleanest pattern uses a Dex::Operation:
class Employee::OnboardingForm < Dex::Form
# ... attributes, validations, nested forms ...
def save
return false unless valid?
case operation.safe.call
in Ok then true
in Err => e then errors.add(:base, e.message) and false
end
end
private
def operation
Employee::Onboard.new(
employee: record, first_name:, last_name:, email:,
department:, position:, start_date:,
address: address.to_h,
emergency_contacts: emergency_contacts.map(&:to_h)
)
end
endThe Operation handles all the multi-model persistence – creating or updating Employee, Department, and Position inside a transaction:
class Employee::Onboard < Dex::Operation
prop :employee, _Nilable(Employee)
prop :first_name, String
prop :last_name, String
prop :email, String
prop :department, String
prop :position, String
prop :start_date, Date
prop :address, Hash
prop :emergency_contacts, _Array(Hash)
def perform
emp = self.employee || Employee.new
emp.update!(first_name:, last_name:, email:)
dept = Department.find_or_create_by!(name: department)
emp.update!(department: dept, position: Position.find_by!(title: position), start_date:)
sync_emergency_contacts(emp)
emp
end
endSimple persistence
For single-model forms, you can skip the Operation:
def save
return false unless valid?
target = record || Employee.new
target.update(name:, email:)
endController pattern
No permit needed – the form's attribute declarations are the whitelist. Just require the top-level key:
class EmployeesController < ApplicationController
def new
@form = Employee::OnboardingForm.new
end
def create
@form = Employee::OnboardingForm.new(params.require(:employee))
if @form.save
redirect_to dashboard_path
else
render :new, status: :unprocessable_entity
end
end
def edit
@form = Employee::OnboardingForm.for(@employee)
end
def update
@form = Employee::OnboardingForm.new(params.require(:employee)).with_record(@employee)
if @form.save
redirect_to dashboard_path
else
render :edit, status: :unprocessable_entity
end
end
endTesting
Forms are standard ActiveModel objects. Test them with plain Minitest – no special helpers needed:
class EmployeeOnboardingFormTest < Minitest::Test
def test_validates_required_fields
form = Employee::OnboardingForm.new
assert form.invalid?
assert form.errors[:email].any?
assert form.errors[:first_name].any?
end
def test_normalizes_email
form = Employee::OnboardingForm.new(email: " [email protected] ")
assert_equal "[email protected]", form.email
end
def test_nested_validation
form = Employee::OnboardingForm.new(
first_name: "Alice", last_name: "Smith",
email: "[email protected]",
department: "Engineering", position: "Dev",
start_date: Date.today,
address: { street: "", city: "", country: "" }
)
assert form.invalid?
assert form.errors[:"address.street"].any?
end
def test_serialization
form = Employee::OnboardingForm.new(
first_name: "Alice", email: "[email protected]",
address: { street: "123 Main", city: "NYC" }
)
h = form.to_h
assert_equal "Alice", h[:first_name]
assert_equal "123 Main", h[:address][:street]
end
end