Value Objects in Ruby: The Idiomatic Way
Imagine you are developing a logistics application and constantly need to work with geographical coordinates. You could represent them as a hash like { lat: 41.3851, lng: 2.1734 } and pass it from method to method. It works, but you soon start noticing problems: Was it lat or latitude? Has the hash been modified somewhere in the flow? Where do I add a method to calculate the distance between two points?
These problems have an elegant solution: value objects. We’re going to explore what they are, why you should use them, and how Ruby 3.2 introduced an idiomatic way to create them with the Data class.
Understanding Value Objects
A value object is a type of object that represents a concept in your domain through its values, not its identity. To understand this distinction, think about the difference between your car (which is that particular, unique, and identifiable car, even if an identical one exists) and a ten-euro bill (interchangeable with any other ten-euro bill). The car has identity, and the bill has value.
Value objects are defined by two fundamental characteristics. Immutability ensures that once the object is created, its attributes cannot change (if you need a different value, you create a new object). Value equality means two objects are considered equal if they have the same type and the same values, regardless of whether they are different instances in memory.
The Problem with Hashes
Using hashes to represent domain concepts creates problems as the code base grows. You start with { lat: 41.3851, lng: 2.1734 } and it seems reasonable, but as the project evolves, inconsistencies appear: in one file, latitude was used instead of lat, or the values were stored as strings. The hash offers no guarantee about its structure.
Furthermore, hashes are mutable. When you pass a hash to a method, that method can modify it, and those modifications will affect any other part of the code that holds a reference to the same hash. Tracking these changes in a large application becomes a nightmare.
Finally, if you want to add behavior related to those coordinates, you don’t have a natural place to put that logic. You end up with helper methods that receive the hash as a parameter, dispersing the logic throughout the code.
A value object solves these three problems with:
- Naming: The structure has a meaningful name.
- Greppability: You can search for all references in your code.
- Flexibility: You can add methods for additional behavior.
Eliminating Boilerplate with Data.define
If you’ve ever created a simple class to group related values, you’ll recognize this repetitive pattern:
class Link attr_reader :url, :source
def initialize(url:, source:) @url = url @source = source endendThat’s ten lines of code for something conceptually very simple. With Data.define, introduced in Ruby 3.2, all that boilerplate disappears:
class Link < Data.define(:url, :source); endThis single line automatically generates the constructor, accessor methods for each attribute, guaranteed immutability, and comparison by value. You can start using the object immediately:
link = Link.new(url: "[https://example.com](https://example.com)", source: "twitter")link.url # => "[https://example.com](https://example.com)"link.source # => "twitter"There are two ways to define a class with Data. The one you’ve seen uses inheritance and is the most common form in Ruby code.
The second way assigns directly to a constant, which is more concise:
Link = Data.define(:url, :source)Both work the same. The inheritance approach feels more familiar to the code reader and follows the usual class definition convention in Ruby.
Four Ways to Create Instances
Ruby offers several syntax options for instantiating Data objects. The clearest uses keyword arguments, making explicit which value corresponds to each attribute:
class GeoLocation < Data.define(:latitude, :longitude)
barcelona = GeoLocation.new(latitude: 41.3851, longitude: 2.1734)You can also use positional arguments if you prefer a more concise syntax, although you sacrifice some clarity:
madrid = GeoLocation.new(40.4168, -3.7038)There is also an alternative bracket syntax that works with both positional and keyword arguments:
# Brackets with positional argumentsvalencia = GeoLocation[39.4699, -0.3763]
# Brackets with keyword argumentsseville = GeoLocation[latitude: 37.3891, longitude: -5.9845]In practice, the keyword arguments form is the most recommended. In addition to being more readable, it’s safer against refactoring: if tomorrow you change the order of attributes in Data.define, positional arguments will silently invert the values, while keywords will continue to work correctly.
Immutability in Practice
One of the most valuable aspects of using Data is that immutability is guaranteed out of the box. You don’t have to remember to freeze the object or worry about someone accidentally modifying it.
When you try to change an attribute of a Data object, Ruby simply won’t let you. There is no setter method for the attributes:
location = GeoLocation.new(latitude: 41.3851, longitude: 2.1734)
location.latitude = 40.0# => NoMethodError: undefined method `latitude=' for #<data GeoLocation...>Even if you try to define a setter manually in the definition block, Ruby will throw a FrozenError when you try to use it. Immutability is integrated into the design of Data.
This guarantee has an important consequence that may surprise you: you cannot use memoization with instance variables. That is, the usual pattern of caching an expensive calculation won’t work:
require 'json'
class Response < Data.define(:body, :status) def parsed_body # This will throw FrozenError because the object is frozen @parsed_body ||= JSON.parse(body, symbolize_names: true) endend
response = Response.new(body: '{"name": "test"}', status: 200)response.parsed_body# => FrozenError: can't modify frozen ResponseIf you need a computed value, you will have to calculate it every time it is requested. In most cases, the cost is very small, and you will maintain the clarity of immutability.
This means that when you pass a value object to any part of your code, you have the certainty that it cannot be modified.
But then, what do you do when you really need to “change” a value? You will have to create a new object using the with method:
original = GeoLocation.new(latitude: 41.3851, longitude: 2.1734)adjusted = original.with(latitude: 41.4000)
# The original object remains intactoriginal.latitude # => 41.3851# The new object has the updated valueadjusted.latitude # => 41.4000This pattern of creating new objects instead of modifying existing ones is fundamental in functional programming and has important benefits for concurrency and reasoning about the code.
Value Equality: Thinking in Terms of Equivalence
The other defining characteristic of value objects is that their equality is determined by their values, not by their identity in memory. To understand why this is important, let’s look at an example:
loc1 = GeoLocation.new(latitude: 41.3851, longitude: 2.1734)loc2 = GeoLocation.new(latitude: 41.3851, longitude: 2.1734)loc3 = GeoLocation.new(latitude: 40.4168, longitude: -3.7038)Although loc1 and loc2 are different objects in memory:
loc1.object_id == loc2.object_id # => falseRuby considers them equal because they have the same values:
loc1 == loc2 # => trueloc3 has different values, so it is not equal:
loc1 == loc3 # => falseThis equality semantic is what you would intuitively expect. If you have the coordinates of Barcelona stored in two different variables, both represent the same place and should be considered equal. Data implements this feature without you having to manually write == or eql? methods.
Adding Behavior with Custom Methods
A value object doesn’t have to be limited to storing data. You can add methods that represent behaviors related to the concept you are modeling. If you use inheritance, you define them in the class body:
class GeoLocation < Data.define(:latitude, :longitude) def to_s "#{latitude}°, #{longitude}°" end
def northern_hemisphere? latitude > 0 endendIf you use direct assignment, you can pass a block to Data.define:
GeoLocation = Data.define(:latitude, :longitude) do def to_s "#{latitude}°, #{longitude}°" end
def northern_hemisphere? latitude > 0 endendNow your value object not only groups data but also knows how to answer questions about itself:
barcelona = GeoLocation.new(latitude: 41.3851, longitude: 2.1734)sydney = GeoLocation.new(latitude: -33.8688, longitude: 151.2093)
barcelona.to_s # => "41.3851°, 2.1734°"barcelona.northern_hemisphere? # => truesydney.northern_hemisphere? # => falseThis ability to encapsulate both data and related behavior is what makes value objects so useful. The logic that operates on the coordinates lives next to the coordinates themselves, instead of being scattered across external helpers or services.
Setting Default Values
Sometimes, some attributes have a reasonable default value that you want to apply when not specified. You can achieve this by overriding the initialize method:
require 'date'
class DateRange < Data.define(:start_date, :end_date) def initialize(start_date:, end_date: Date.today) super end
def days (end_date - start_date).to_i end
def includes?(date) date >= start_date && date <= end_date endendWith this definition, you can create a range by specifying only the start date:
current_period = DateRange.new(start_date: Date.new(2025, 1, 1))current_period.end_date # => Today's datecurrent_period.days # => Days elapsed since January 1stType Validation Using Pattern Matching
Ruby 3.0 introduced pattern matching, and this feature combines elegantly with Data to validate attribute types. Instead of writing explicit validations with conditionals, you can use the pattern matching syntax in the initializer:
class Dimensions < Data.define(:width, :height, :depth) def initialize(width:, height:, depth:) width => Numeric height => Numeric depth => Numeric super end
def volume width * height * depth endendNow, if you try to create an object with incorrect type values, Ruby will immediately reject the operation:
# This works correctlybox = Dimensions.new(width: 30, height: 40, depth: 20)box.volume # => 24000
# This throws an error because "30" is a String, not a NumericDimensions.new(width: "30", height: 40, depth: 20)# => NoMatchingPatternError: "30": Numeric === "30" does not return trueComposition: Value Objects Inside Value Objects
Value objects particularly shine when you compose them with each other to model more complex structures in your domain. Imagine you are building a blog system and want to represent both authors and posts:
class Author < Data.define(:name, :email) def to_s "#{name} <#{email}>" endend
class BlogPost < Data.define(:title, :author, :published_at) def initialize(title:, author:, published_at:) author => Author super end
def to_s %{"#{title}" by #{author.name}} endendThe type validation in the BlogPost initializer ensures that you always receive a correctly formed Author object:
post = BlogPost.new( title: "Value Objects in Ruby", author: david, published_at: Time.now)
post.to_s # => "\"Value Objects in Ruby\" by David"A very useful pattern when working with external data is to define alternative class constructors that encapsulate the transformation logic:
class GeoLocation < Data.define(:latitude, :longitude) def self.from_string(str) lat, lng = str.split(',').map { |v| v.strip.to_f } new(latitude: lat, longitude: lng) end
def to_s "#{latitude}°, #{longitude}°" endend
location = GeoLocation.from_string("41.3851, 2.1734")location.latitude # => 41.3851This pattern is useful when you receive data in a different format than the constructor expects, such as coordinates in text from a form or an API. The transformation logic is encapsulated in a single place.
An Important Warning: Immutability is Shallow
There is one detail to consider about Data’s immutability: the object itself is frozen, but the objects it contains as attributes are not automatically frozen. This is known as “shallow freeze”.
To illustrate the problem, imagine you create a value object that contains an array:
class ShoppingCart < Data.define(:items); end
cart = ShoppingCart.new(items: ["apples", "bread"])The cart object is frozen, but the items array is not:
cart.items << "milk" # This works!cart.items # => ["apples", "bread", "milk"]This can break your expectations about immutability. The solution is to ensure that all objects you pass as attributes are also immutable:
class ShoppingCart < Data.define(:items) def initialize(items:) super(items: items.freeze) endend
cart = ShoppingCart.new(items: ["apples", "bread"])cart.items << "milk"# => FrozenError: can't modify frozen ArrayThe best practice is to use value objects as attributes of other value objects whenever possible and meaningful. If not, freeze the collections you use if you don’t need mutability at that point in your design.
Data versus Struct: Choosing the Right Tool
Ruby has another similar class called Struct that also allows you to create classes with named attributes. The main difference is mutability: Struct creates mutable objects by default.
PersonStruct = Struct.new(:name, :age, keyword_init: true)person = PersonStruct.new(name: "Anna", age: 30)person.age = 31 # This works
PersonData = Data.define(:name, :age)person = PersonData.new(name: "Anna", age: 30)person.age = 31 # NoMethodErrorRegarding performance, benchmarks show that Data.define and Struct with keyword arguments have virtually identical performance, both in object creation and attribute access.
Ruby also offers OpenStruct, which allows you to create attributes dynamically after instantiating the object:
require 'ostruct'
person = OpenStruct.new(name: "Anna")person.age = 30 # Adds the attribute on the flyperson.city = "Barcelona" # And another oneThis flexibility comes at a cost: OpenStruct is between 2x and 85x slower than Data or Struct depending on the operation. Avoid it in code where performance matters.
Therefore:
- Use
Datawhen you want immutable value objects (which should be most cases) - Reserve
Structfor situations where you need mutability or are working with older Ruby versions.
Practical Examples
Let’s look at a couple of use cases where value objects are especially useful.
The first is defining global lists of value objects that represent options or constants in your domain. This pattern is very common in Rails applications:
class Task < ApplicationRecord Priority = Data.define(:name, :level, :color)
PRIORITIES = [ Priority.new(name: :low, level: 1, color: 'gray'), Priority.new(name: :medium, level: 2, color: 'yellow'), Priority.new(name: :high, level: 3, color: 'orange'), Priority.new(name: :critical, level: 4, color: 'red') ].freeze
enum priority: PRIORITIES.each_with_object({}) { |p, h| h[p.name] = p.level }endIn the views:
Task::PRIORITIES.each do |priority| puts "#{priority.name}: #{priority.level}"endThe second use case is encapsulating the result of operations that can succeed or fail. Instead of returning mixed values (sometimes the object, sometimes an error, sometimes nil), we create a value object that explicitly represents both cases:
class OperationResult < Data.define(:success, :value, :errors) def initialize(success:, value: nil, errors: []) super end
def success? = success def failure? = !success
def self.success(value) new(success: true, value: value) end
def self.failure(errors) new(success: false, errors: Array(errors)) endend
def create_user(params) # The email is required return OperationResult.failure("The email is required") if params[:email].nil? # The email is not valid return OperationResult.failure("The email is not valid") unless params[:email].include?("@")
user = User.create(params) OperationResult.success(user)end
if result.success? # User created puts "User created: #{result.value.name}"else # Could not create the user puts "Could not create the user: #{result.errors.join(', ')}"endThis pattern eliminates ambiguity about what a method can return: you always receive the same type of object, without having to guess if it will return the user, nil, or if it will raise an exception.
Conclusion
The Data class provides a clean and expressive way to create value objects with all the features you would expect: immutability, value equality, and the flexibility to add custom methods.
If you use modern Ruby and find yourself passing related hashes or arrays of values through your code, consider whether a value object could make that code clearer, safer, and easier to maintain.
Test your knowledge
-
What are the two fundamental characteristics of a value object?
-
What happens if you try to use memoization with instance variables inside a
Dataobject?
-
You have a
Dataobject that contains an array as an attribute. What happens if you modify that array?
-
What is the main difference between
DataandStruct?