Value Object Semantics in Ruby

Ask questions Research chat →

https://thoughtbot.com/blog/value-object-semantics-in-ruby · scraped

ruby

Attachments

Scraped Content

— 624 words · 2026-02-14 02:57:09 UTC ·

Excerpt

There are a bunch of tricky edge-cases to look out for when implementing a value object in Ruby. I often catch these when reviewing pull requests. It’s possible that someone sent a link here from just such a review! Here is a handy overview of expected value object semantics in Ruby. Value objects are richer domain objects that typically replace the use of a primitive. For example, we might have a Duration class that represents an amount of time instead of representing this value as a raw integer (this is separate from the core Time class that represents a moment in time). class Duration def initialize(minutes) @minutes = minutes end end There are two high-level “concepts of equality” in Ruby: Equal by identity (objects being compared are the same object in memory) Equal by value (objects being compared represent the same value) By default, primitives and structs compare by value while instances of user-created classes compare by identity. When we create a value object, we
There are a bunch of tricky edge-cases to look out for when implementing a value object in Ruby. I often catch these when reviewing pull requests. It’s possible that someone sent a link here from just such a review! Here is a handy overview of expected value object semantics in Ruby. Value objects are richer domain objects that typically replace the use of a primitive. For example, we might have a Duration class that represents an amount of time instead of representing this value as a raw integer (this is separate from the core Time class that represents a moment in time). class Duration def initialize(minutes) @minutes = minutes end end There are two high-level “concepts of equality” in Ruby: Equal by identity (objects being compared are the same object in memory) Equal by value (objects being compared represent the same value) By default, primitives and structs compare by value while instances of user-created classes compare by identity. When we create a value object, we are trying to override the default to compare by value rather than by identity. For example, a duration object representing 5 minutes should be considered equal to a different instance also representing 5 minutes but doesn’t by default because it is an instance of a user-created class. Duration.new(5) == Duration.new(5) # => false Two value objects that represent the same value should: == each other NOT equal? each other (equal? should compare by identity, not by value) hash to the same value (so we don’t break hash keys) eql? each other (Ruby expects objects that have the same hash to be eql? to each other) class Duration attr_reader :minutes def initialize(minutes) @minutes = minutes end def ==(other) minutes == other.minutes end alias_method :eql?, :== def hash [self.class, minutes].hash end end #hash and #eql? are important to allow our value object to be used as a hash key correctly. This allows us to index into hashes with any object that’s equal to their key (our natural intuition) rather than only being allowed to use the exact same instance. events_by_duration = { Duration.new(5) => [event1, event3], Duration.new(10) => [event2] } # This would return nil if hash and eql? where not overriden since # we are techinically using a different instance than was used # when defining the hash. five_min_events = events_by_duration[Duration.new(5)] As of Ruby 3.2, you probably want to use the immutable Data class for your value objects. Struct is still available if you want something mutable. Ruby structs implement these semantics by default! They can be an easy way to cheaply add value objects to your project, although I like to move to a real object once I start needing custom methods. Duration = Struct.new(:minutes) Duration.new(5) == Duration.new(5) # => true While the equality rules above are the only ones that are strictly required, below are extra behaviors I commonly add to my value objects. In general, value objects should also be immutable. Any methods that would modify the object (e.g. addition) should instead return a new instance. Bonus points for freezing the object in the constructor. class Duration def initialize(minutes) @minutes = minutes freeze end def +(other) Duration.new(self.minutes + other.minutes) end end Additionally, it may be nice to implement <=> and include Comparable. This can get your == for free. It will also allow you to use your value in a range. Add succ if you want ranges of your object to be iterable. Want to try and remember to implement these correctly on all value objects on your project? This may be an actually good use for an RSpec shared example. See this gist for what that might look like. RSpec.describe Duration do it_behaves_like "a value object" end If you enjoyed this post, you might also like:

Visibility

Visible to everyone

Reading Status

Related Bookmarks

My Note


Saved!

Annotations

Export as Markdown
+ Annotate selection

Add Annotation