CrackedRuby logo

CrackedRuby

Value Objects

Documentation for implementing immutable value objects that encapsulate data with value-based equality semantics in Ruby applications.

Patterns and Best Practices Code Organization
11.3.4

Overview

Value Objects represent immutable data structures where equality depends on their attributes rather than object identity. Ruby implements Value Objects through classes that override equality methods and prevent state mutation after initialization. The pattern encapsulates related data with behavior while maintaining immutability guarantees.

Ruby provides several approaches for creating Value Objects. The Struct class offers a built-in foundation, while custom classes provide complete control over behavior. The Data class, introduced in Ruby 3.2, specifically targets Value Object creation with immutable-by-default semantics.

# Using Struct
Point = Struct.new(:x, :y) do
  def magnitude
    Math.sqrt(x**2 + y**2)
  end
end

point1 = Point.new(3, 4)
point2 = Point.new(3, 4)
point1 == point2  # => true
point1.magnitude  # => 5.0
# Using Data (Ruby 3.2+)
class Money < Data.define(:amount, :currency)
  def +(other)
    raise ArgumentError unless currency == other.currency
    self.class.new(amount + other.amount, currency)
  end
end

price = Money.new(100, 'USD')
tax = Money.new(10, 'USD')
total = price + tax  # => Money(amount: 110, currency: "USD")
# Custom implementation
class Color
  attr_reader :red, :green, :blue

  def initialize(red, green, blue)
    @red = red.freeze
    @green = green.freeze
    @blue = blue.freeze
    freeze
  end

  def ==(other)
    other.is_a?(Color) &&
      red == other.red &&
      green == other.green &&
      blue == other.blue
  end

  def hash
    [red, green, blue].hash
  end
end

Value Objects excel in domain modeling, configuration objects, and data transfer between system boundaries. They eliminate temporal coupling issues and provide thread-safe data containers without synchronization overhead.

Basic Usage

Creating Value Objects starts with identifying data that belongs together and defining immutable containers. Struct provides the simplest approach for basic Value Objects with automatic equality and accessor methods.

Person = Struct.new(:first_name, :last_name, :email) do
  def full_name
    "#{first_name} #{last_name}"
  end

  def domain
    email.split('@').last
  end
end

john = Person.new('John', 'Doe', 'john@example.com')
jane = Person.new('Jane', 'Smith', 'jane@company.com')

john.full_name  # => "John Doe"
jane.domain     # => "company.com"

The Data class offers enhanced immutability with built-in freezing and more restrictive mutation prevention. Data objects automatically freeze their instances and provide with method for creating modified copies.

class Address < Data.define(:street, :city, :zip_code)
  def formatted
    "#{street}\n#{city}, #{zip_code}"
  end
end

home = Address.new('123 Main St', 'Anytown', '12345')
work = home.with(street: '456 Business Ave', city: 'Downtown')

home.formatted  # => "123 Main St\nAnytown, 12345"
work.formatted  # => "456 Business Ave\nDowntown, 12345"

Custom Value Object implementations provide complete control over behavior and validation. Define equality methods, implement proper hashing, and ensure immutability through defensive copying or freezing.

class Temperature
  attr_reader :value, :unit

  def initialize(value, unit)
    @value = value.to_f
    @unit = unit.to_s.upcase
    validate_unit!
    freeze
  end

  def ==(other)
    other.is_a?(Temperature) &&
      celsius == other.celsius
  end

  def hash
    celsius.hash
  end

  def celsius
    case unit
    when 'C' then value
    when 'F' then (value - 32) * 5.0 / 9.0
    when 'K' then value - 273.15
    end
  end

  private

  def validate_unit!
    unless %w[C F K].include?(unit)
      raise ArgumentError, "Invalid unit: #{unit}"
    end
  end
end

freezing = Temperature.new(32, 'F')
zero_celsius = Temperature.new(0, 'C')
freezing == zero_celsius  # => true

Value Objects work effectively in collections due to proper equality and hash implementations. They can serve as hash keys and participate in set operations without identity confusion.

coordinates = Set.new
coordinates << Point.new(0, 0)
coordinates << Point.new(1, 1) 
coordinates << Point.new(0, 0)  # Duplicate

coordinates.size  # => 2

locations = {
  Point.new(0, 0) => 'Origin',
  Point.new(1, 1) => 'Northeast'
}

locations[Point.new(0, 0)]  # => "Origin"

Advanced Usage

Value Objects support sophisticated composition patterns where complex domain concepts emerge from simpler components. Composition maintains immutability while enabling rich domain modeling through layered abstractions.

class DateRange < Data.define(:start_date, :end_date)
  def initialize(start_date:, end_date:)
    raise ArgumentError if start_date > end_date
    super
  end

  def duration
    end_date - start_date
  end

  def overlaps?(other)
    start_date <= other.end_date && end_date >= other.start_date
  end

  def contains?(date)
    date >= start_date && date <= end_date
  end

  def split_at(date)
    raise ArgumentError unless contains?(date)
    [
      self.class.new(start_date: start_date, end_date: date),
      self.class.new(start_date: date, end_date: end_date)
    ]
  end
end

class Reservation < Data.define(:guest_name, :room_number, :date_range, :rate)
  def total_cost
    date_range.duration * rate
  end

  def conflicts_with?(other)
    room_number == other.room_number && 
      date_range.overlaps?(other.date_range)
  end

  def extend_stay(new_end_date)
    with(date_range: date_range.with(end_date: new_end_date))
  end
end

Transformation pipelines leverage immutability to create processing chains without side effects. Each transformation returns new Value Objects, maintaining data lineage and enabling rollback capabilities.

class Money < Data.define(:amount, :currency)
  def convert_to(target_currency, exchange_rate)
    self.class.new(
      amount: (amount * exchange_rate).round(2),
      currency: target_currency
    )
  end

  def apply_tax(tax_rate)
    with(amount: (amount * (1 + tax_rate)).round(2))
  end

  def apply_discount(discount_percentage)
    discount_amount = amount * (discount_percentage / 100.0)
    with(amount: (amount - discount_amount).round(2))
  end

  def split(parts)
    part_amount = (amount / parts).round(2)
    remainder = amount - (part_amount * parts)
    
    Array.new(parts) { |i| 
      extra = i == 0 ? remainder : 0
      self.class.new(part_amount + extra, currency)
    }
  end
end

base_price = Money.new(100.00, 'USD')
final_price = base_price
  .apply_discount(10)      # 10% discount
  .apply_tax(0.08)         # 8% tax
  .convert_to('EUR', 0.85) # Convert to EUR

# => Money(amount: 97.2, currency: "EUR")

Generic Value Object factories enable consistent creation patterns across domain models. Factory methods encapsulate validation logic and provide clear construction interfaces.

class Coordinate < Data.define(:latitude, :longitude)
  VALID_LAT_RANGE = (-90.0..90.0)
  VALID_LON_RANGE = (-180.0..180.0)

  def initialize(latitude:, longitude:)
    unless VALID_LAT_RANGE.cover?(latitude)
      raise ArgumentError, "Invalid latitude: #{latitude}"
    end
    unless VALID_LON_RANGE.cover?(longitude)
      raise ArgumentError, "Invalid longitude: #{longitude}"
    end
    super
  end

  def self.from_degrees(lat_deg, lon_deg)
    new(latitude: lat_deg.to_f, longitude: lon_deg.to_f)
  end

  def self.from_dms(lat_dms, lon_dms)
    lat = dms_to_decimal(lat_dms)
    lon = dms_to_decimal(lon_dms)
    new(latitude: lat, longitude: lon)
  end

  def distance_to(other)
    # Haversine formula implementation
    earth_radius = 6371.0 # kilometers
    
    lat1_rad = Math::PI * latitude / 180
    lat2_rad = Math::PI * other.latitude / 180
    delta_lat = Math::PI * (other.latitude - latitude) / 180
    delta_lon = Math::PI * (other.longitude - longitude) / 180

    a = Math.sin(delta_lat/2)**2 + 
        Math.cos(lat1_rad) * Math.cos(lat2_rad) * 
        Math.sin(delta_lon/2)**2
    
    c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a))
    earth_radius * c
  end

  private

  def self.dms_to_decimal(dms_hash)
    degrees = dms_hash[:degrees]
    minutes = dms_hash[:minutes] || 0
    seconds = dms_hash[:seconds] || 0
    
    degrees + minutes/60.0 + seconds/3600.0
  end
end

Testing Strategies

Value Object testing focuses on equality semantics, immutability guarantees, and behavioral correctness. Test equality reflexivity, symmetry, and transitivity to ensure proper hash table behavior and collection operations.

RSpec.describe Temperature do
  describe 'equality' do
    it 'implements reflexive equality' do
      temp = Temperature.new(25, 'C')
      expect(temp).to eq(temp)
    end

    it 'implements symmetric equality' do
      temp1 = Temperature.new(32, 'F')
      temp2 = Temperature.new(0, 'C')
      
      expect(temp1 == temp2).to eq(temp2 == temp1)
    end

    it 'implements transitive equality' do
      temp1 = Temperature.new(0, 'C')
      temp2 = Temperature.new(32, 'F') 
      temp3 = Temperature.new(273.15, 'K')

      expect(temp1).to eq(temp2)
      expect(temp2).to eq(temp3)
      expect(temp1).to eq(temp3)
    end

    it 'maintains consistent hash values for equal objects' do
      temp1 = Temperature.new(100, 'C')
      temp2 = Temperature.new(212, 'F')
      
      expect(temp1).to eq(temp2)
      expect(temp1.hash).to eq(temp2.hash)
    end
  end

  describe 'immutability' do
    it 'prevents modification of instance variables' do
      temp = Temperature.new(25, 'C')
      
      expect(temp).to be_frozen
      expect { temp.instance_variable_set(:@value, 30) }
        .to raise_error(FrozenError)
    end

    it 'creates frozen string attributes' do
      temp = Temperature.new(25, 'celsius')
      
      expect(temp.unit).to be_frozen
    end
  end
end

Mock collaborators when Value Objects interact with external systems while preserving value semantics in test scenarios. Use test doubles for expensive operations like external API calls or complex calculations.

RSpec.describe Money do
  describe '#convert_to' do
    let(:exchange_service) { instance_double('ExchangeService') }
    
    it 'converts currency using external service' do
      allow(exchange_service).to receive(:rate_for)
        .with('USD', 'EUR')
        .and_return(0.85)
      
      usd_amount = Money.new(100, 'USD')
      eur_amount = usd_amount.convert_to('EUR', exchange_service.rate_for('USD', 'EUR'))
      
      expect(eur_amount).to eq(Money.new(85, 'EUR'))
    end
  end

  describe 'arithmetic operations' do
    it 'maintains immutability during operations' do
      original = Money.new(100, 'USD')
      result = original.apply_discount(10)
      
      expect(original.amount).to eq(100)
      expect(result.amount).to eq(90)
      expect(original).not_to equal(result)
    end
  end
end

Property-based testing validates Value Object invariants across wide input ranges. Generate random inputs to verify equality properties and immutability constraints hold under all conditions.

RSpec.describe Coordinate do
  include RSpec::Parameterized

  where_it 'maintains distance symmetry' do
    coord1 = Coordinate.new(
      latitude: rand(-90.0..90.0),
      longitude: rand(-180.0..180.0)
    )
    coord2 = Coordinate.new(
      latitude: rand(-90.0..90.0), 
      longitude: rand(-180.0..180.0)
    )

    distance1 = coord1.distance_to(coord2)
    distance2 = coord2.distance_to(coord1)
    
    expect(distance1).to be_within(0.001).of(distance2)
  end

  it 'validates boundary conditions' do
    valid_cases = [
      [-90, -180], [90, 180], [0, 0],
      [-89.999, -179.999], [89.999, 179.999]
    ]
    
    valid_cases.each do |lat, lon|
      expect { Coordinate.new(latitude: lat, longitude: lon) }
        .not_to raise_error
    end

    invalid_cases = [
      [-90.001, 0], [90.001, 0], [0, -180.001], [0, 180.001]
    ]
    
    invalid_cases.each do |lat, lon|
      expect { Coordinate.new(latitude: lat, longitude: lon) }
        .to raise_error(ArgumentError)
    end
  end
end

Production Patterns

Value Objects serve as domain primitives that encapsulate business rules and prevent primitive obsession. They provide type safety and domain-specific behavior while maintaining serialization compatibility with persistence layers.

class UserId < Data.define(:value)
  UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i

  def initialize(value:)
    unless UUID_PATTERN.match?(value.to_s)
      raise ArgumentError, "Invalid UUID format: #{value}"
    end
    super(value: value.to_s)
  end

  def self.generate
    require 'securerandom'
    new(value: SecureRandom.uuid)
  end

  def to_s
    value
  end

  def to_param
    value
  end
end

class OrderNumber < Data.define(:prefix, :sequence, :year)
  def initialize(prefix:, sequence:, year: Date.current.year)
    super
  end

  def self.parse(order_string)
    match = order_string.match(/\A([A-Z]+)(\d+)-(\d{4})\z/)
    raise ArgumentError, "Invalid order format" unless match
    
    new(
      prefix: match[1],
      sequence: match[2].to_i,
      year: match[3].to_i
    )
  end

  def to_s
    "#{prefix}#{sequence.to_s.rjust(6, '0')}-#{year}"
  end

  def next
    with(sequence: sequence + 1)
  end
end

# Usage in domain models
class Order
  attr_reader :id, :number, :customer_id, :items

  def initialize(id:, number:, customer_id:, items:)
    @id = UserId.new(value: id)
    @number = OrderNumber.parse(number) 
    @customer_id = UserId.new(value: customer_id)
    @items = items.freeze
  end
end

API serialization patterns maintain Value Object semantics across system boundaries. Implement custom serialization methods that preserve domain meaning while supporting JSON and other interchange formats.

class PhoneNumber < Data.define(:country_code, :area_code, :number)
  def initialize(country_code:, area_code:, number:)
    @country_code = country_code.to_s
    @area_code = area_code.to_s
    @number = number.to_s
    validate_format!
    super
  end

  def self.parse(phone_string)
    # Remove non-digits
    digits = phone_string.gsub(/\D/, '')
    
    case digits.length
    when 10
      new(country_code: '1', area_code: digits[0,3], number: digits[3,7])
    when 11 
      new(country_code: digits[0,1], area_code: digits[1,3], number: digits[4,7])
    else
      raise ArgumentError, "Invalid phone number: #{phone_string}"
    end
  end

  def to_s
    "+#{country_code} (#{area_code}) #{number[0,3]}-#{number[3,4]}"
  end

  def to_json(*args)
    {
      country_code: country_code,
      area_code: area_code, 
      number: number,
      formatted: to_s
    }.to_json(*args)
  end

  def self.from_json(json_data)
    data = JSON.parse(json_data)
    new(
      country_code: data['country_code'],
      area_code: data['area_code'],
      number: data['number']
    )
  end

  private

  def validate_format!
    unless country_code.match?(/\A\d{1,3}\z/) &&
           area_code.match?(/\A\d{3}\z/) &&
           number.match?(/\A\d{7}\z/)
      raise ArgumentError, "Invalid phone number format"
    end
  end
end

Caching strategies for expensive Value Object construction leverage immutability guarantees. Cache instances safely since they cannot change state after creation.

class GeoLocation < Data.define(:latitude, :longitude)
  @cache = {}
  @cache_mutex = Mutex.new

  def self.new(latitude:, longitude:)
    key = [latitude, longitude]
    
    @cache_mutex.synchronize do
      @cache[key] ||= super
    end
  end

  def self.clear_cache
    @cache_mutex.synchronize do
      @cache.clear
    end
  end

  def self.cache_size
    @cache.size
  end

  # Expensive calculation worth caching
  def timezone
    @timezone ||= calculate_timezone
  end

  private

  def calculate_timezone
    # Expensive lookup or calculation
    TimeZoneLookup.find_by_coordinates(latitude, longitude)
  end
end

Common Pitfalls

Mutable nested objects break Value Object immutability guarantees. Freeze nested collections and implement defensive copying to prevent indirect mutation through contained objects.

# PROBLEMATIC - array is mutable
class Tags < Data.define(:items)
  def initialize(items:)
    super(items: items)  # Array not frozen!
  end
end

tags = Tags.new(items: ['ruby', 'programming'])
tags.items << 'web'  # Mutates the Value Object!

# CORRECT - defensive freezing
class Tags < Data.define(:items)
  def initialize(items:)
    super(items: items.dup.freeze)
  end

  def add(tag)
    with(items: items + [tag])
  end

  def remove(tag) 
    with(items: items - [tag])
  end
end

# CORRECT - immutable collections
require 'hamster'

class Tags < Data.define(:items)
  def initialize(items:)
    items_set = items.is_a?(Hamster::Set) ? items : Hamster::Set.new(items)
    super(items: items_set)
  end

  def add(tag)
    with(items: items.add(tag))
  end

  def include?(tag)
    items.include?(tag)
  end
end

Inheritance hierarchies complicate equality semantics when subclasses add attributes. Value Objects should prefer composition over inheritance to avoid Liskov substitution violations.

# PROBLEMATIC - inheritance breaks equality
class Point < Data.define(:x, :y)
end

class ColoredPoint < Point
  attr_reader :color
  
  def initialize(x:, y:, color:)
    super(x: x, y: y)
    @color = color
  end

  def ==(other)
    super && other.respond_to?(:color) && color == other.color
  end
end

point = Point.new(x: 1, y: 2) 
colored = ColoredPoint.new(x: 1, y: 2, color: 'red')

point == colored   # => false  
colored == point   # => false (breaks symmetry)

# CORRECT - composition approach
class Point < Data.define(:x, :y)
end

class Color < Data.define(:name)
end

class ColoredPoint < Data.define(:point, :color)
  def x
    point.x
  end

  def y  
    point.y
  end

  def move(dx, dy)
    with(point: Point.new(x: x + dx, y: y + dy))
  end
end

Performance issues arise with excessive object creation in tight loops. Consider object pooling or mutable builders for performance-critical sections, then convert to immutable Value Objects.

# SLOW - creates many temporary objects
def calculate_trajectory(points)
  result = []
  points.each_cons(2) do |p1, p2|
    # Creates intermediate Point objects
    midpoint = Point.new(
      x: (p1.x + p2.x) / 2.0,
      y: (p1.y + p2.y) / 2.0  
    )
    velocity = Point.new(
      x: p2.x - p1.x,
      y: p2.y - p1.y
    )
    result << TrajectorySegment.new(midpoint: midpoint, velocity: velocity)
  end
  result
end

# FASTER - builder pattern
class PointBuilder
  attr_accessor :x, :y

  def initialize(x = 0, y = 0)
    @x, @y = x, y
  end

  def to_point
    Point.new(x: x, y: y)
  end
end

def calculate_trajectory(points)
  midpoint_builder = PointBuilder.new
  velocity_builder = PointBuilder.new
  
  points.each_cons(2).map do |p1, p2|
    midpoint_builder.x = (p1.x + p2.x) / 2.0
    midpoint_builder.y = (p1.y + p2.y) / 2.0
    
    velocity_builder.x = p2.x - p1.x  
    velocity_builder.y = p2.y - p1.y

    TrajectorySegment.new(
      midpoint: midpoint_builder.to_point,
      velocity: velocity_builder.to_point
    )
  end
end

Hash key instability occurs when objects used as hash keys become corrupted or lose proper equality implementations. Always implement both == and hash methods consistently.

# BROKEN - hash not implemented
class BadKey
  attr_reader :value
  
  def initialize(value)
    @value = value
  end

  def ==(other)
    other.is_a?(BadKey) && value == other.value
  end
  # Missing hash method!
end

cache = {}
key1 = BadKey.new('test')
key2 = BadKey.new('test') 

cache[key1] = 'data'
cache[key2]  # => nil (different hash values!)

# CORRECT - consistent hash implementation  
class GoodKey
  attr_reader :value

  def initialize(value)
    @value = value.freeze
    freeze
  end

  def ==(other)
    other.is_a?(GoodKey) && value == other.value
  end

  def hash
    [self.class, value].hash
  end
end

Reference

Core Value Object Classes

Class Purpose Mutability Built-in Methods
Struct Quick Value Objects Mutable by default ==, hash, to_a, to_h
Data Immutable Value Objects Immutable ==, hash, with, deconstruct
Custom Full control Configurable Implement manually

Essential Method Implementations

Method Signature Purpose Implementation Notes
#== ==(other) Value equality Check class and compare attributes
#hash hash Hash table support Use attributes.hash or [class, *attrs].hash
#eql? eql?(other) Hash equality Usually delegates to ==
#freeze freeze Prevent mutation Freeze self and nested objects
#dup dup Shallow copy Create new instance with same values

Data Class Methods

Method Parameters Returns Description
#with **changes Data instance Create copy with attribute changes
#deconstruct None Array Return attributes for pattern matching
#deconstruct_keys keys Hash Return named attributes for pattern matching

Validation Patterns

# Constructor validation
def initialize(value)
  raise ArgumentError, "Invalid value" unless valid?(value)
  @value = value.freeze
  freeze
end

# Factory method validation  
def self.create(raw_input)
  normalized = normalize(raw_input)
  validate!(normalized)
  new(normalized)
end

# Range validation
def initialize(percentage)
  unless (0..100).cover?(percentage)
    raise ArgumentError, "Percentage must be 0-100"
  end
  @percentage = percentage
end

Serialization Patterns

# JSON serialization
def to_json(*args)
  { 
    type: self.class.name,
    attributes: { attr1: @attr1, attr2: @attr2 }
  }.to_json(*args)
end

def self.from_json(json_string)
  data = JSON.parse(json_string)
  new(**data['attributes'].transform_keys(&:to_sym))
end

# Hash conversion
def to_h
  { attr1: @attr1, attr2: @attr2 }
end

def self.from_h(hash)
  new(**hash.transform_keys(&:to_sym))
end

Thread Safety Guarantees

Pattern Thread Safety Notes
Frozen objects Complete Cannot be modified after creation
Immutable attributes Complete References cannot change
Lazy initialization Requires mutex Cache expensive calculations safely
Class-level caches Requires synchronization Use Mutex for cache access

Common Error Types

Error Class Situation Prevention
ArgumentError Invalid constructor parameters Validate inputs in initialize
FrozenError Attempting mutation Implement proper freezing
NoMethodError Missing equality methods Implement ==, hash, eql?
TypeError Type mismatches Add type checking in comparisons