After trying out form objects like the SignUp
example from last week, you’ll quickly see the value of extracting common logic into a reusable module. Let’s take our SignUp
class and transform it into a flexible, module that you can apply across your app.
Here’s where we are starting:
class SignUp
include ActiveModel::Model
def user
@user ||= User.new
end
def user_attributes=(attributes)
user.assign_attribute(attributes)
end
def account
@account ||= Account.new
end
def account_attributes=(attributes)
account.assign_attribute(attributes)
end
def save!
ActiveRecord::Base.transaction do
user.save!
account.user = user
account.save!
end
end
end
To simplify and standardize how we create form objects, we’ll move the ActiveModel::Model
inclusion into a shared FormObject
module. Here’s the updated code:
module FormObject
extend ActiveSupport::Concern
include ActiveModel::Model
end
class SignUp
include FormObject
# include ActiveModel::Model # removed
# …
end
This small change sets the foundation for reusable form object logic across your application.
The most common use case for a form object is to manage relationships with other objects. We’ll add a has_one
method to our module to dynamically create accessors and *_attributes=
methods for nested objects. Here’s how it looks:
module FormObject
class_methods do
def has_one(name)
class_name = name.to_s.titleize
class_eval <<-CODE, __FILE__, __LINE__ + 1
def #{name}
@#{name} ||= #{class_name}.new
end
def #{name}_attributes=(attributes)
#{name}.assign_attributes(attributes)
end
CODE
end
end
end
class SignUp
include FormObject
has_one :user
has_one :account
# …
end
This is the most complex part of the module. What we are doing here is writing the code dynamically and adding it to the class. When calling has_one :user
, the class_eval
is essentially adding the following to the class:
def user
@user ||= User.new
end
def user_attributes=(attributes)
user.assign_attributes(attributes)
end
This approach aligns with accepts_nested_attributes conventions, making the form compatible with Rails’ helpers for nested forms.
To ensure all associated objects are saved correctly, we’ll track the added relationships and save them in a transaction to prevent partial saves:
module FormObject
class_methods do
def has_one(name)
class_name = name.to_s.titleize
@form_object_associations ||= []
@form_object_associations << name
#…
end
end
def save
ActiveRecord::Base.transaction do
@form_object_associations.each { |r| send(r).save! }
end
end
end
This ensures that either all objects are saved successfully, or none are, maintaining data consistency.
Since validates_associated
isn’t part of ActiveModel::Model
, we’ll add a custom shim to validate associated objects. We’ll also ensure all relationships are validated before saving:
module FormObject
class_methods do
def has_one(name)
# …
validates_associated(name)
end
def validates_associated(name)
validates_with
ActiveRecord::Validations::AssociatedValidator,
{ attributes: name }
end
end
def custom_validation_context? = false # needed for validates_associated
def save!
raise ActiveRecord::RecordInvalid unless valid?
ActiveRecord::Base.transaction do
@form_object_associations.each { |r| send(r).save! }
end
end
end
With this addition, your form objects can validate nested relationships as expected.
We’re done! With just 50 lines of code, we’ve created a reusable, flexible FormObject module. Here’s the final implementation:
module FormObject
extend ActiveSupport::Concern
include ActiveModel::Model
class_methods do
def has_one(name)
class_name = name.to_s.titleize
@form_object_associations ||= []
@form_object_associations << name
class_eval <<-CODE, __FILE__, __LINE__ + 1
def #{name}
@#{name} ||= #{class_name}.new
end
def #{name}_attributes=(attributes)
#{name}.assign_attributes(attributes)
end
CODE
validates_associated name
end
def validates_associated(name)
validates_with
ActiveRecord::Validations::AssociatedValidator,
{ attributes: name }
end
end
def custom_validation_context? = false
def save!
raise ActiveRecord::RecordInvalid unless valid?
ActiveRecord::Base.transaction do
@form_object_associations.each { |r| send(r).save! }
end
end
end
class SignUp
include FormObject
has_one :user
has_one :account
end
This implementation covers most common use cases for form objects. For a more feature-rich solution, check out YAAF, a robust library for handling form objects in Rails.