Value Objects in Ruby: The Idiomatic Way

David Morales David Morales
/
Abstract representation of a digital object that encapsulates a value

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:

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
end
end

That’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); end

This 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 arguments
valencia = GeoLocation[39.4699, -0.3763]
# Brackets with keyword arguments
seville = 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)
end
end
response = Response.new(body: '{"name": "test"}', status: 200)
response.parsed_body
# => FrozenError: can't modify frozen Response

If 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 intact
original.latitude # => 41.3851
# The new object has the updated value
adjusted.latitude # => 41.4000

This 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 # => false

Ruby considers them equal because they have the same values:

loc1 == loc2 # => true

loc3 has different values, so it is not equal:

loc1 == loc3 # => false

This 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
end
end

If 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
end
end

Now 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? # => true
sydney.northern_hemisphere? # => false

This 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
end
end

With 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 date
current_period.days # => Days elapsed since January 1st

Type 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
end
end

Now, if you try to create an object with incorrect type values, Ruby will immediately reject the operation:

# This works correctly
box = Dimensions.new(width: 30, height: 40, depth: 20)
box.volume # => 24000
# This throws an error because "30" is a String, not a Numeric
Dimensions.new(width: "30", height: 40, depth: 20)
# => NoMatchingPatternError: "30": Numeric === "30" does not return true

Composition: 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}>"
end
end
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}}
end
end

The type validation in the BlogPost initializer ensures that you always receive a correctly formed Author object:

david = Author.new(name: "David", email: "[email protected]")
post = BlogPost.new(
title: "Value Objects in Ruby",
author: david,
published_at: Time.now
)
post.to_s # => "\"Value Objects in Ruby\" by David"
post.author.email # => "[email protected]"

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}°"
end
end
location = GeoLocation.from_string("41.3851, 2.1734")
location.latitude # => 41.3851

This 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)
end
end
cart = ShoppingCart.new(items: ["apples", "bread"])
cart.items << "milk"
# => FrozenError: can't modify frozen Array

The 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 # NoMethodError

Regarding 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 fly
person.city = "Barcelona" # And another one

This 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:

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 }
end

In the views:

Task::PRIORITIES.each do |priority|
puts "#{priority.name}: #{priority.level}"
end

The 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))
end
end
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
result = create_user(email: "[email protected]", name: "David")
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(', ')}"
end

This 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

  1. What are the two fundamental characteristics of a value object?

  1. What happens if you try to use memoization with instance variables inside a Data object?

  1. You have a Data object that contains an array as an attribute. What happens if you modify that array?

  1. What is the main difference between Data and Struct?