Ruby 3.2's Data Class — Immutable Value Objects Done Right
If you’ve been writing Ruby for a while, you’ve probably cobbled together something to represent simple value objects — a Struct, a frozen hash, maybe a plain class with an attr_reader and a manual ==. They work, but none of them feel quite right. Ruby 3.2 ships Data, a built-in class for creating immutable value objects, and it’s one of those additions that makes you wonder how you got by without it.
What Problem Data Solves
Value objects are everywhere in real codebases. A Money object with an amount and currency. A Point with x and y coordinates. A DateRange with a start and end. These aren’t database records — they don’t have identity, they don’t get mutated, they’re just structured data that two pieces of code agree on.
Before Data, your options were:
Struct— mutable by default, equality based on values, but you can set attributes after creation- Frozen
Struct— works, but freezing happens at the instance level; you have to remember to do it - Plain class — verbose, requires manual
==,hash, andinspectimplementations
Data gives you a purpose-built solution: immutable, value-equality semantics, and a clean API out of the box.
The Basics
Example:
Point = Data.define(:x, :y)
origin = Point.new(x: 0, y: 0)
other = Point.new(x: 0, y: 0)
origin == other # => true
origin.frozen? # => true
origin.x = 5 # => FrozenError (can't modify frozen Point)
You get keyword-argument constructors, value-based equality, and frozen instances automatically. No boilerplate.
Data.define returns a new class, so you can assign it to a constant like any other class, or use it inline as an anonymous type.
Defining Methods on a Data Class
Data classes aren’t just dumb structs — you can add methods directly in the block form.
Example:
Point = Data.define(:x, :y) do
def distance_from_origin
Math.sqrt(x**2 + y**2)
end
def translate(dx:, dy:)
with(x: x + dx, y: y + dy)
end
end
p = Point.new(x: 3, y: 4)
p.distance_from_origin # => 5.0
p.translate(dx: 1, dy: 0) # => #<data Point x=4, y=4>
p.x # => 3 — original unchanged
The with method is key here: it creates a new instance with some values replaced, leaving the original untouched. This is the immutable update pattern that functional programmers reach for constantly. Ruby now ships it natively.
Data vs. Struct — What Actually Differs
| Feature | Struct |
Data |
|---|---|---|
| Mutability | Mutable by default | Always frozen |
| Equality | Value-based | Value-based |
| Keyword-only constructor | Opt-in (Ruby 3.2+) | Always keyword-only |
with method |
No | Yes |
| Positional args | Supported | Not supported |
| Intended use | Lightweight mutable record | Immutable value object |
If you need to mutate the object after creation, use Struct. If mutation is a bug, use Data.
When Struct is still the right call
A configuration object that gets built up incrementally before being locked down is a good fit for Struct. A coordinate, a currency amount, a validated email address — those are Data territory.
Real-World Use: Domain Value Objects
Example:
Money = Data.define(:amount, :currency) do
def to_s
"#{currency} #{format('%.2f', amount)}"
end
def +(other)
raise ArgumentError, "Currency mismatch" unless currency == other.currency
with(amount: amount + other.amount)
end
end
price = Money.new(amount: 19.99, currency: "USD")
tax = Money.new(amount: 1.60, currency: "USD")
total = price + tax
puts total # => USD 21.59
total.frozen? # => true
price.amount # => 19.99 — untouched
This is a real pattern from financial applications. The immutability guarantee means you can pass a Money value around your codebase without defensive copies. Whoever receives it cannot accidentally modify the original.
Pattern Matching with Data
Data objects work cleanly with Ruby’s pattern matching because they expose their members as deconstructable keys.
Example:
Point = Data.define(:x, :y)
point = Point.new(x: 3, y: -1)
case point
in { x: 0, y: 0 }
puts "origin"
in { x:, y: } if y.negative?
puts "below x-axis at #{x}"
in { x:, y: }
puts "at #{x}, #{y}"
end
# => below x-axis at 3
Data and pattern matching are natural partners — both are designed around working with structured, immutable values.
Pro-Tip: When defining domain-level value objects with
Data, add avalid?class method or validation in the constructor usinginitialize(called viasuper). SinceDatais frozen on creation, validation in the constructor is your only chance to catch bad inputs before they propagate through your system.
Conclusion
Data fills a gap that Ruby developers have been papering over for years. It’s not a large API surface — define, new, with, and your own methods — but it’s exactly the right size for what it does. Immutable value objects with value equality, clean constructors, and first-class pattern matching support. If you’re on Ruby 3.2 or later, Struct is no longer the automatic choice for structured data. Reach for Data first and only drop back to Struct when you genuinely need mutability.
FAQs
Q1: Can I inherit from a Data class?
No. Data classes are final — you cannot subclass them. If you need shared behavior across multiple value types, extract it into a module and include it in each Data.define block.
Q2: Does Data work with JSON serialization?
Not automatically. You’ll need to implement to_h (which Data provides) and use it with JSON.generate(point.to_h). For deserialization, add a class-level .from_h or .from_json method that calls new with the parsed keys.
Q3: How is Data different from a frozen Struct?
Data is always keyword-only, has the with method for producing modified copies, and signals intent more clearly — anyone reading the code knows this is a value object, not a mutable record that someone forgot to freeze.
Q4: Can I add private methods to a Data class?
Yes. The block passed to Data.define is evaluated in the class context, so you can use private, define helper methods, include modules, and do anything else you’d do in a regular class definition.
Q5: What Ruby version do I need?
Data was introduced in Ruby 3.2.0. If you’re on 3.0 or 3.1, Struct with .new.freeze is the closest alternative. Upgrade to 3.2+ for the native implementation.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀