Value objects for domain modeling
Sarah Mitchell
Feb 2026
2 tabs
class Money
include Comparable
attr_reader :amount, :currency
def initialize(amount, currency = 'USD')
@amount = BigDecimal(amount.to_s)
@currency = currency.upcase
freeze # Make immutable
end
def +(other)
ensure_same_currency!(other)
Money.new(amount + other.amount, currency)
end
def -(other)
ensure_same_currency!(other)
Money.new(amount - other.amount, currency)
end
def *(multiplier)
Money.new(amount * multiplier, currency)
end
def /(divisor)
Money.new(amount / divisor, currency)
end
def <=>(other)
ensure_same_currency!(other)
amount <=> other.amount
end
def ==(other)
other.is_a?(Money) &&
amount == other.amount &&
currency == other.currency
end
alias eql? ==
def hash
[amount, currency].hash
end
def to_s
"#{currency_symbol}#{formatted_amount}"
end
def to_f
amount.to_f
end
def zero?
amount.zero?
end
def positive?
amount.positive?
end
def negative?
amount.negative?
end
private
def ensure_same_currency!(other)
return if currency == other.currency
raise ArgumentError,
"Cannot operate on different currencies: #{currency} and #{other.currency}"
end
def currency_symbol
{ 'USD' => '$', 'EUR' => '€', 'GBP' => '£' }[currency] || currency
end
def formatted_amount
format('%.2f', amount)
end
end
# Usage
price = Money.new(19.99, 'USD')
tax = Money.new(2.50, 'USD')
total = price + tax
# => $22.49
discount = total * 0.10
final_price = total - discount
# Comparison
price > tax # => true
[price, tax, discount].sort # => sorted by amount
# Equality
Money.new(10, 'USD') == Money.new(10, 'USD') # => true
Money.new(10, 'USD') == Money.new(10, 'EUR') # => false
# Using in models
class Order < ApplicationRecord
def total_price
Money.new(total_cents / 100.0, 'USD')
end
def total_price=(money)
self.total_cents = (money.amount * 100).to_i
end
end
order = Order.new
order.total_price = Money.new(99.99)
order.total_price # => $99.99
# Address value object
class Address
attr_reader :street, :city, :state, :zip_code, :country
def initialize(street:, city:, state:, zip_code:, country: 'USA')
@street = street
@city = city
@state = state
@zip_code = zip_code
@country = country
validate!
freeze
end
def full_address
"#{street}, #{city}, #{state} #{zip_code}, #{country}"
end
def ==(other)
other.is_a?(Address) &&
street == other.street &&
city == other.city &&
state == other.state &&
zip_code == other.zip_code &&
country == other.country
end
private
def validate!
raise ArgumentError, "Invalid address" if [street, city, state, zip_code].any?(&:blank?)
end
end
# DateRange value object
class DateRange
include Enumerable
attr_reader :start_date, :end_date
def initialize(start_date, end_date)
@start_date = start_date.to_date
@end_date = end_date.to_date
raise ArgumentError, "End date must be after start date" if @end_date < @start_date
freeze
end
def include?(date)
date = date.to_date
start_date <= date && date <= end_date
end
def days
(end_date - start_date).to_i + 1
end
def each(&block)
(start_date..end_date).each(&block)
end
def overlaps?(other)
start_date <= other.end_date && end_date >= other.start_date
end
def ==(other)
other.is_a?(DateRange) &&
start_date == other.start_date &&
end_date == other.end_date
end
def to_s
"#{start_date} to #{end_date}"
end
end
# Coordinate value object
class Coordinate
include Comparable
attr_reader :latitude, :longitude
EARTH_RADIUS_KM = 6371
def initialize(latitude, longitude)
@latitude = latitude.to_f
@longitude = longitude.to_f
validate!
freeze
end
def distance_to(other)
# Haversine formula
lat1 = to_radians(latitude)
lat2 = to_radians(other.latitude)
delta_lat = to_radians(other.latitude - latitude)
delta_lon = to_radians(other.longitude - longitude)
a = Math.sin(delta_lat / 2)**2 +
Math.cos(lat1) * Math.cos(lat2) *
Math.sin(delta_lon / 2)**2
c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
EARTH_RADIUS_KM * c
end
def ==(other)
other.is_a?(Coordinate) &&
latitude == other.latitude &&
longitude == other.longitude
end
def to_s
"#{latitude}, #{longitude}"
end
private
def validate!
unless latitude.between?(-90, 90) && longitude.between?(-180, 180)
raise ArgumentError, "Invalid coordinates"
end
end
def to_radians(degrees)
degrees * Math::PI / 180
end
end
# Usage
address = Address.new(
street: '123 Main St',
city: 'San Francisco',
state: 'CA',
zip_code: '94102'
)
range = DateRange.new('2024-01-01', '2024-01-31')
range.days # => 31
range.include?(Date.today)
range.each { |date| puts date }
sf = Coordinate.new(37.7749, -122.4194)
la = Coordinate.new(34.0522, -118.2437)
distance = sf.distance_to(la) # => ~559 km
2 files · ruby
Explain with highlit
Value objects represent immutable domain concepts without identity. I use value objects for money, addresses, date ranges, coordinates. Value objects are compared by value, not identity—two identical addresses are equal. They're immutable—create new instances rather than modifying. Value objects encapsulate validation and behavior. Including Comparable enables sorting and comparison. Value objects reduce primitive obsession—Money instead of decimals, Address instead of strings. They make code expressive and type-safe. Testing value objects is straightforward—pure Ruby without dependencies. Understanding when to use value objects vs. entities improves domain modeling. Value objects are fundamental to Domain-Driven Design.