Move Over, Struct: Meet Ruby's New Data.define
Source: Dev.to
Introduction
With the introduction of Ruby 3.2, Data.define is now the preferred way to handle DTOs (Data Transfer Objects) in modern Ruby applications. While Struct has been the go‑to for years, it has several quirks that make it less‑than‑ideal for pure data transfer. Data was specifically designed to be a value object factory.
Data.define ?
Data is a class factory (similar to Struct) used to define simple, immutable objects.
# Define a Data class
Address = Data.define(:city, :zip)
# Initialize it
home = Address.new(city: "Vilnius", zip: "01100")
# Access data
home.city # => "Vilnius"
Immutable by Design
The biggest difference between Data and Struct is that Data objects are immutable—there are no setter methods.
Point = Data.define(:x, :y)
p1 = Point.new(x: 1, y: 2)
p1.x = 10
# NoMethodError (undefined method `x=' for #)
In a DTO context, immutability is a massive advantage because it ensures that data remains consistent as it is passed through different layers of your application.
Flexible initialization
Data accepts both positional and keyword arguments out of the box.
Point = Data.define(:x, :y)
# Both work:
p1 = Point.new(1, 2)
p2 = Point.new(x: 1, y: 2)
Note: When using keywords, they are validated. Passing a typo (e.g.,
z: 3) raises anArgumentError.
Equality and pattern matching
Two different instances of a Data object are considered equal if their values are equal.
p1 = Point.new(1, 2)
p2 = Point.new(1, 2)
p1 == p2 # => true
p1.eql?(p2) # => true
Data objects work perfectly with Ruby’s pattern matching (case/in).
case p1
in Point(x, y) if x > 0
puts "X is positive: #{x}"
end
Adding custom logic
Just like Struct, you can pass a block to Data.define to add methods.
Money = Data.define(:amount, :currency) do
def to_s
"#{amount} #{currency}"
end
end
puts Money.new(100, "USD").to_s # => "100 USD"
Data vs. Struct
| Feature | Data.define (Ruby 3.2+) | Struct |
|---|---|---|
| Mutability | Immutable (read‑only) | Mutable (read/write) |
| Initialization | Positional or keywords | Positional (keywords must be enabled) |
| Enumerable methods | No | Yes (.each, .map, etc.) |
| Intended use | Value objects / DTOs | Lightweight “property” classes |
| Interface size | Minimal (clean) | Large (inherits many methods) |
When to still use Struct
Even though Data is usually better for DTOs, Struct remains useful in certain scenarios:
- Mutability needed – when the object serves as a temporary “scratchpad” that is updated frequently.
- Enumerable needed – when you want to call
.each,.select, etc., directly on the object’s attributes. - Legacy Ruby – when your environment runs Ruby 3.1 or older, where
Data.defineis unavailable.
Data.define for DTOs
In a typical Rails or Dry‑Ruby stack, DTOs move data from a service object to a view or from an API response to a model. Data.define is advantageous because:
- It prevents accidental side effects (no one can change a value halfway through a request).
- The code is more “honest”—the object is just data.
- Built‑in support for keyword arguments makes the code far more readable than positional
Structarguments.
Example of a modern DTO
UserDTO = Data.define(:id, :email, :role)
# In a controller or service
user_data = UserDTO.new(id: user.id, email: user.email, role: user.role)