Getting your Trinity Audio player ready...
|

Introduction
In the world of Ruby on Rails development, keeping your codebase clean and maintainable is crucial. As projects grow, complexity often increases, and without clear separation of concerns, your models and controllers can quickly become cluttered.
One effective strategy to simplify Rails code is by using form objects and value objects. In particular, these patterns help developers extract logic out of models and controllers, thereby simplifying business logic and reducing redundancy.
In this article, we’ll explore how to simplify Rails code with form and value objects, understand their benefits, and learn how to implement them effectively.
Challenges With Basic Code (Without Form and Value Objects)
In the initial code example, both user and address details are managed directly within the controller. This tightly coupled approach results in poor separation of concerns, leading to a bloated controller that handles validation logic and the instantiation of associated models. As a result, the code becomes harder to maintain, less reusable, and more error-prone. Validation logic, such as email format checks, is duplicated, and there is limited flexibility to modify user or address handling independently, making future enhancements more cumbersome.
Controller:
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def create
@user = User.new(user_params)
# Build associated address
@user.build_address(address_params)
# Manually validate email format
if invalid_email?(@user.email)
@user.errors.add(:email, "is invalid")
end
if @user.valid? && @user.address.valid?
@user.save
redirect_to @user, notice: "User registered successfully!"
else
render :new, alert: "There was an error with registration."
end
end
private
def user_params
params.require(:user).permit(:name, :email, :password, :password_confirmation)
end
def address_params
params.require(:address).permit(:street, :city, :state, :zip)
end
def invalid_email?(email)
email !~ /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
end
end
Models:
# app/models/user.rb
class User < ApplicationRecord
has_one :address, dependent: :destroy
validates :name, :email, :password, :password_confirmation, presence: true
validates :password, confirmation: true
end
# app/models/address.rb
class Address < ApplicationRecord
belongs_to :user
validates :street, :city, :state, :zip, presence: true
end
Problems Without Form and Value Objects in Rails:
- Overloaded Controller: Consequently, the controller is burdened with handling validation (e.g., invalid_email?) and constructing related models (e.g., address), thereby increasing complexity and reducing maintainability.
- Duplication of Validation Logic: On the other hand, in the earlier approach, the duplication of validation logic—such as email format checks—was embedded directly in the controller. Consequently, this logic was not reusable and made the controller bulky and difficult to extend.
- Lack of Separation: Therefore, both user and address information are handled together, making it difficult to adjust one without affecting the other. Moreover, this lack of separation limits the flexibility needed to scale the application efficiently.
How Form Objects Help Simplify Rails Code:
For example, form objects act as an intermediary between form input and models, capturing form-specific logic and validations. By introducing a form object like UserRegistrationForm
, we can centralize validation logic, encapsulate construction logic, and simplify the controller responsibilities.
Form Object:
# app/forms/user_registration_form.rb
class UserRegistrationForm
include ActiveModel::Model
attr_accessor :name, :email, :password, :password_confirmation, :street, :city, :state, :zip
validates :name, :email, :password, :password_confirmation, :street, :city, :state, :zip, presence: true
validates :password, confirmation: true
validate :validate_email_format
def save
return false unless valid?
user = User.new(name: name, email: email, password: password, password_confirmation: password_confirmation)
user.build_address(street: street, city: city, state: state, zip: zip)
user.save
end
private
def validate_email_format
unless email =~ /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
errors.add(:email, "is invalid")
end
end
end
Updated Controller:
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def create
form = UserRegistrationForm.new(user_registration_params)
if form.save
redirect_to user_path(User.last), notice: "User registered successfully!"
else
render :new, alert: "There was an error with registration."
end
end
private
def user_registration_params
params.require(:user_registration).permit(:name, :email, :password, :password_confirmation, :street, :city, :state, :zip)
end
end
Benefits of Using Form Objects in Rails:
- Centralized Validation: All validation logic is centralized in the
UserRegistrationForm
. - Cleaner Controller: As a result, the controller now focuses solely on handling the request and response.
- Simplified Testing: Moreover, testing becomes much more straightforward. Since the
UserRegistrationForm
operates independently of the controller, it can be unit-tested in isolation, leading to faster and more focused test coverage.
How Value Objects Simplify and Reuse Rails Code:
Value objects encapsulate specific behavior and logic, thereby allowing for consistent and reusable patterns across your application. For instance, by creating value objects for entities like Address
and Email
, you can isolate domain-specific rules and behaviors, making your code easier to manage and extend.
Address Value Object:
# app/value_objects/address.rb
class Address
attr_reader :street, :city, :state, :zip
def initialize(street:, city:, state:, zip:)
@street = street
@city = city
@state = state
@zip = zip
validate!
end
def ==(other)
street == other.street && city == other.city &&
state == other.state && zip == other.zip
end
private
def validate!
raise "Invalid address details" if [@street, @city, @state, @zip].any?(&:blank?)
end
end
Email Value Object:
# app/value_objects/email.rb
class Email
attr_reader :address
def initialize(address)
@address = address
validate!
end
private
def validate!
unless @address =~ /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
raise ArgumentError, "Email format is invalid"
end
end
end
Combining Form and Value Objects to Simplify Rails Code:
# app/forms/user_registration_form.rb
class UserRegistrationForm
include ActiveModel::Model
attr_accessor :name, :email, :password, :password_confirmation, :street, :city, :state, :zip
validates :name, :password, :password_confirmation, presence: true
validates :password, confirmation: true
def save
return false unless valid?
# Create value objects
email_object = Email.new(email)
address_object = Address.new(street: street, city: city, state: state, zip: zip)
# Use value objects to initialize User and Address models
user = User.new(
name: name,
email: email_object.address, # Extract address from the Email object
password: password,
password_confirmation: password_confirmation
)
user.build_address(address_object.to_h)
user.save
rescue ArgumentError => e
errors.add(:base, e.message)
false
end
end
Other Practical Uses of Value Objects
In addition to simplifying form objects, Email
and Address
value objects can be reused across your Rails application to maintain consistency, enforce domain rules, and avoid duplication.
Here are some practical examples:
1. Using Email
Value Object in a Controller (e.g., Resident Registration)
Instead of manually validating email formats in the controller, use the Email
value object directly during resident registration.
# app/controllers/residents_controller.rb
class ResidentsController < ApplicationController
def create
# Use Email value object to validate and normalize email input
email = Email.new(resident_params[:email])
# Initialize Resident with normalized email
@resident = Resident.new(resident_params.merge(email: email.address))
if @resident.save
redirect_to @resident, notice: "Resident created successfully!"
else
render :new, alert: "Error creating resident."
end
rescue ArgumentError => e
# Handle invalid email error from the value object
redirect_to new_resident_path, alert: "Invalid email: #{e.message}"
end
private
def resident_params
params.require(:resident).permit(:name, :email)
end
end
✅ This ensures email validation happens automatically, right at the boundary where data enters the system.
2. Using Address
Value Object in a Background Job (e.g., Resident CSV Import)
When importing resident addresses from a CSV file, you can validate each address using the Address
value object before saving, ensuring data integrity.
# app/jobs/resident_address_import_job.rb
class ResidentAddressImportJob < ApplicationJob
queue_as :default
def perform(csv_file_path)
CSV.foreach(csv_file_path, headers: true) do |row|
begin
# Create Address value object to validate and normalize address input
address = Address.new(
street: row["street"],
city: row["city"],
state: row["state"],
zip: row["zip"]
)
# Find resident by email (we assume emails are unique)
resident = Resident.find_by(email: row["email"])
next unless resident
# Assign address attributes from the validated Address object
resident.create_address!(
street: address.street,
city: address.city,
state: address.state,
zip: address.zip
)
rescue => e
# Log errors without failing the entire job
Rails.logger.error("Failed to import address for #{row['email']}: #{e.message}")
end
end
end
end
✅ Using Address
ensures only valid addresses are added to residents, and errors are caught early.
3. Auto-Normalizing Email in Other Models (e.g., Resident)
You can use the Email
value object in other models to automatically validate and normalize email addresses before saving.
Example:
# app/models/resident.rb
class Resident < ApplicationRecord
validates :name, presence: true
validates :email, presence: true
before_validation :normalize_email
private
def normalize_email
# Use Email value object to validate and clean the email before saving
self.email = Email.new(email).address
rescue ArgumentError => e
# Capture invalid email errors at the model level
errors.add(:email, e.message)
end
end
✅ This ensures that all residents’ emails are properly formatted and validated, without repeating validation logic everywhere.
Summary:
By utilizingAddress
value objects not just in form objects but also in controllers, background jobs, and model callbacks, you create a more consistent, maintainable, and reliable application.
Value objects encapsulate domain rules, leading to better design, cleaner code, and fewer bugs.
Final Thoughts on Simplifying Rails Code with Form and Value Objects:
By using form objects and value objects in Rails, you can significantly simplify your codebase, thereby improving readability and enhancing long-term maintainability. These design patterns not only encapsulate business logic but also reduce duplication, which in turn makes your application more robust and easier to scale.
For further learning, you may want to explore the following resources, which will provide deeper insights into the topic
- Rails Guides on POROs
- Thoughtbot’s blog on design patterns
- Domain-Driven Design by Eric Evans
- Learn design patterns and anti-design patterns
Senior Software Engineer