Value objects
Table of Contents
:ID: F6ABC426-2ADF-405E-821A-6189532066DD
Sometimes I find myself coding business logic around object’s property. Depending on how much is involved, it might make sense to extract an object that represents the value to contain that business logic.
# Color example
I had for example an object with a color
property, whose value is a color hex
code. I wanted to know if the color was considered dark or light, the RGB
representation and if it was the default color all the objects are instantiated
with.
In this case, I created a ColorType value object
class ColorType DEFAULT = 'e4f2fe'.freeze attr_reader :hex def initialize(hex = nil) @hex = hex.presence || DEFAULT end def to_rgb [r, g, b] end def r = hex[0..1].to_i(16) def g = hex[2..3].to_i(16) def b = hex[4..5].to_i(16) def darkish? to_rgb.sum < (255 * 3) / 3.6 end def default? hex == DEFAULT end end
Which I then wrapped around the object’s color value (ActiveRecord object in this case)
class Memo < ApplicationRecord def color ColorType.new(super) end end
Example usage.
irb(main):001> m = Memo.new => #<Memo:0x0000ffff67460160 ... irb(main):002> m.color => #<ColorType:0x0000ffff5fe732e0 @hex="e4f2fe"> irb(main):003> m.color.hex => "e4f2fe" irb(main):004> m.color.to_rgb => [228, 242, 254] irb(main):005> m.color.darkish? => false irb(main):006> m.color.default? => true
Why do this? All these methods could just be added to the model, but it kinda begs for a separate object. Not only for the behavior encapsulation, but reusability if needed.
We could do more with this. Need equality comparison? That can added:
def ==(other) hex == other.hex end
# Why value objects
- Clarity: Value objects provide a clear and expressive way to represent domain concepts, making the code more readable and easier to understand. For me this is the primary reason.
- Reusability: Value objects can be easily reused across different parts of the application, as they are self-contained and do not have any external dependencies.
- Immutability: Value objects should be immutable. This makes them thread-safe and easier to reason about, as they cannot be accidentally modified by other parts of the application.
# Some canonical examples
# Money
class Money attr_reader :amount def initialize(amount) @amount = amount end def +(other) Money.new(amount + other.amount) end def -(other) Money.new(amount - other.amount) end def ==(other) amount == other.amount end end
# Address
class Address attr_reader :street, :city, :state, :zip def initialize(street, city, state, zip) @street = street @city = city @state = state @zip = zip end def ==(other) street == other.street && city == other.city && state == other.state && zip == other.zip end def to_s "#{street}, #{city}, #{state} #{zip}" end end