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 |