Creating ruby Value Objects: The Idiomatic way

When writing Ruby OOP, a typical pattern might be to create an object to group multiple values together meaningfully and sometimes also add some extra methods (computed properties, predicates, representations, …) to allow the object to respond to various situations.

Here is an exploration of how to create value-alike objects in Ruby and what I think is the modern idiomatic way.

What is a value-alike object?

If you want to read an article about this concept, I recommend Value Object by Martin Fowler. He explains this concept very well with examples and references. I invite you to read that article. It is not that long.

They are simple objects that have the following properties:

  • Comparable by type and value (that means two objects having the same values as attributes and being the same class will be equal)
  • Immutable (once set their attributes, they should not be allowed to be changed)

The concept is useful when you have to carry around multiple related values, and you need them together in most cases. When talking about Ruby and Ruby OOP, a value object is a simple immutable object that represents a concept in your domain and knows to respond to some simple messages like: what is the value of this property, what is your representation as a string, are you equal to this object in value and type and so on.

The simplest example is a Point that in 2D has x and y properties.

Thus, instead of carrying on a hash { x: 20, y: 30 }, you carry on an object: Point.new(x: 20, y:20) that you can interrogate and ask questions. For example, you might want to know in which quadrant the point is, and making this an object allows you to add methods to it, like quadrant

Or I can ask distance_to(another_point), and it will tell me something along the lines of Math.sqrt((x - other.x) ** 2 + (y - other.y) ** 2)

But most of the time, you need to ask that object point.x or point.y and have a good name for both the class and the attributes.

Why use a value object?

You might ask yourself, why use a value object?

Here are some arguments for using a value object:

  1. Keep data together using an immutable object so you can be sure that as you pass an instance of this object through your code, nothing has changed it.
  2. It is a group of values that should be together under a meaningful name
  3. It is greppable, meaning you can easily find all the places in your codebase where this object is used, making it simple to refactor when needed.

When comparing a value object with using a more base structure like a Hash, the advantages over Hash are:

  • Naming: the structure of the values together have a name
  • Greppability: because now this structure has a name, it is easier to find it in the codebase
  • Flexibility: you can add utility methods, computed properties, or predicates

The Idiomatic Ruby way since 2022

The idiomatic way to do this in Ruby should be to use Data class. Here is what Ruby’s official documentation says about it:

Class Data provides a convenient way to define simple classes for value-alike objects.

Screenshot of the definition of the Data object in Ruby|800

We must thank Victor Shepelev for proposing this structure in Ruby and continuing the discussion about it until it was accepted and implemented.

How to define and create an instance of a Data class

Say you want to pass along an object representing an amount and save the value and the currency.

You can define this like:

Price = Data.define(:amount, :currency)

There are multiple ways to instantiate an objects from a Data class:

Using required keyword arguments

price = Price.new(amount: 50, currency: "USD")
# => #<data Price amount=50, currency="USD">

Using positional arguments

price = Price.new(50, "USD")
# => #<data Price amount=50, currency="USD">

Using alternative form construct

price = Price["50", "USD"]
# => #<data Price amount="50", currency="USD">

Using alternative construction hash-like form

price = Price[amount: "50", currency: "USD"]
# => #<data Price amount="50", currency="USD">

Properties of the Data object in Ruby

A Data object has two important properties in my view:

  1. It is immutable so there are no setters defined and you cannot define setters
# You cannot change the value
 
price = Price.new(amount: 50, currency: "USD")
 
# The following will raise an exception
price.amount = 50 
# => `<main>': undefined method `amount=' for #<data Price amount=50, currency="USD"> (NoMethodError)

Even if you try to define a setter, it will not work:

Price = Data.define(:amount, :currency) do
    def amount=(new_amount)
      @amount = new_amount
    end
end
 
price = Price.new(amount: 50, currency: "USD")
price.amount = 100 # This will return an exception
# => in 'Price#amount=': can't modify frozen #<Class:#<Price:0x00000001259166a0>>: #<data Price amount=20, currency="USD"> (FrozenError)
  1. It is comparable by type and value
Price = Data.define(:amount, :currency)
 
price1 = Price.new(amount: 50, currency: "USD")
 
price2 = Price.new(amount: 40, currency: "USD")
 
price3 = Price.new(amount: 50, currency: "USD")
 
# While these are all different objects
puts price1.object_id
puts price2.object_id
puts price3.object_id
 
# You will notice that price1 and price3 will be equal
price1 == price3 # true
price1 == price2 # false

It has some other properties but I think these two are the main ones that we should think about when using Data as a value object.

Changing one or many values

The only way to achieve this is to use with and create a new value object:

price_a = Price.new(amount: 50, currency: "USD")
 
price_b = price_a.with(amount: 100)
# => #<data Price amount=100, currency="USD">

Defining custom methods

You might notice that if you browse the documentation that the Data object has a limited number of methods: there are 10 methods specific to this object (not taking into consideration of course methods inherited from its ancestors: [Object, Kernel, BasicObject])

But you can define custom computed properties on it if you want to by passing a block to define:

Price = Data.define(:amount, :currency) do
    def to_s 
        "#{currency} #{amount}"
    end
end
 
price = Price.new(amount: 50, currency: "USD")
price.to_s # => "USD 50"

Default values using an initializer

In case you want to have default values, you can override the initializer like this:

Price = Data.define(:amount, :currency) do
    def initialize(amount:, currency: "USD") 
        super
    end
end
 
# with the shortest form possible, like
Price = Data.define(:amount, :currency) do
    def initialize(amount:, currency: "USD") = super
end

Checking the kind (or type) of the attributes

While this is not a feature of Data object, I feel like it is working nice along with it: you can check for type (or, better said, kind of the arguments) using Pattern Matching:

Price = Data.define(:amount, :currency) do
    def initialize(amount:, currency:)
        amount => Integer
        currency => String
 
        super
    end
end

This can be useful if you, for example want to define Currency as:

Currency = Data.define(:code, :name, :symbol, :decimal)
USD = Currency.new(code: "USD", name: "US Dollar", symbol: "$", decimal: 2)
EUR = Currency.new(code: "EUR", name: "Euro", symbol: "€", decimal: 2)
 
Price = Data.define(:amount, :currency) do
    def initialize(amount:, currency:)
        amount => Integer
        currency => Currency
 
        super
    end
end
 
# ✅
p1 = Price.new(amount: 10, currency: USD) 
# => #<data Price amount=10, currency=#<data Currency code="USD", name="US Dollar", symbol="$", decimal=2>>
 
# ❌
p2 = Price.new(amount: 10, currency: "USD")
# => in `initialize': "USD": Currency === "USD" does not return true (NoMatchingPatternError)

Data class vs Struct

You can use Struct just as well as using the Data class but it will not create immutable objects. You can of course freeze the instances but the Data object gives that for free.

In case you want to create a Struct here is how it might look like:

Price = Struct.new(:amount, :currency, keyword_init: true)
 
price = Price.new(amount: 50, currency: "USD")
# => <struct Price amount=50, currency="USD">

It supports also providing a block when creating the structure to add extra methods as we did it for the Data.

A bit on the immutability

Please note that the Data objects are frozen but not deep frozen. That means that if you put inside a Data object attribute a structure that is not immutable then you can change those properties.

My advice is to combine Data objects only with Data objects or immutable or immediate objects to make sure that everything remains immutable.

I recommend you to do this:

Currency = Data.define(:code, :name, :symbol, :decimal)
Price = Data.define(:amount, :currency)
 
EUR = Currency.new(code: "EUR", name: "EURO", symbol: "€", decimal: 2)
price = Price.new(amount: 50, currency: EUR)
 
price.currency.code = "MyEUR"
# undefined method 'code=' for an instance of Currency (NoMethodError)

While if you try to put there an object that can be changed like say a Struct it will allow you to change it:

Currency = Struct.new(:code, :name, :symbol, :decimal, keyword_init: true)
Price = Data.define(:amount, :currency)
 
EUR = Currency.new(code: "EUR", name: "EURO", symbol: "€", decimal: 2)
price = Price.new(amount: 50, currency: EUR)
# => #<data Price amount=50, currency=#<struct Currency code="EUR", name="EURO", symbol="€", decimal=2>>
 
price.currency.code = "MyEUR"
price
# => #<data Price amount=50, currency=#<struct Currency code="MyEUR", name="EURO", symbol="€", decimal=2>>

The same goes for putting inside a hash:

Price = Data.define(:amount, :currency)
EUR = { code: "EUR", name: "EURO", symbol: "€", decimal: 2 }
 
price = Price.new(amount: 50, currency: EUR)
# => #<data Price amount=50, currency={code: "EUR", name: "EURO", symbol: "€", decimal: 2}>
price.currency[:code] = "MyEUR"
price
# => #<data Price amount=50, currency={code: "MyEUR", name: "EURO", symbol: "€", decimal: 2}>

So you need to take care of freezing the objects that you put inside yourself if you want them to remain unchanged.

Trick: You can use Ractor shareable to deep freeze a Data object

This is probably not the purpose of the Ractor.make_shareable method, but it is a nice trick to try:

Currency = Struct.new(:code, :name, :symbol, :decimal, keyword_init: true)
Price = Data.define(:amount, :currency)
 
EUR = Currency.new(code: "EUR", name: "EURO", symbol: "€", decimal: 2)
price = Ractor.make_shareable(Price.new(amount: 50, currency: EUR))
# => #<data Price amount=50, 
# => currency=#<struct Currency code="EUR", name="EURO", symbol="€", decimal=2>>
 
price.currency.code = "MyEUR"
# => in '<main>': can't modify frozen 
# => Currency: #<struct Currency code="EUR", name="EURO", symbol="€", decimal=2> (FrozenError)

Some conclusions

I think the Data is a great addition to the Ruby language and I try to reach out to it everytime I need to create a value object. I like the immutability and the comparison by type and value that is created by default.

It also defines deconstruct and deconstruct_keys if you want to use the Data object in pattern matching (Struct defines them too in case you want to use Struct).

Read more

I published a followup article where I show some real world examples of value objects created with the Data class.

If you want to read more about this I recommend at least two resources: