Getting your Trinity Audio player ready...
Design patterns - form and value objects

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 utilizing Email and Address 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

  1. Rails Guides on POROs
  2. Thoughtbot’s blog on design patterns
  3. Domain-Driven Design by Eric Evans
  4. Learn design patterns and anti-design patterns

Leave a Reply

Login with