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.
Here is a deep dive into Data.define, how it works, and how it compares to Struct.
What is 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"
Key Features of Data.define
1. 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 #<data Point x=1, y=2>)
In a DTO context, immutability is a massive advantage. It ensures that data remains consistent as it is passed through different layers of your application.
2. Flexible Initialization (Positional or Keywords)
Data is smarter about how it accepts arguments. You can use positional arguments or 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: If you use keywords, they are checked. Passing a typo (e.g., z: 3) will raise an ArgumentError.
3. Value Equality
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
4. Pattern Matching Integration
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
5. Adding Methods
Just like Struct, you can pass a block to Data.define to add custom logic.
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 | No | Yes (has .each, .map, etc.) |
| Intention | Value Objects / DTOs | Lightweight "Property" classes |
| Interface | Minimal (Clean) | Large (Inherits many methods) |
When to use Struct instead?
Even though Data is usually better for DTOs, Struct is still useful if:
- You need mutability: If you are using the object as a temporary "scratchpad" where you update values frequently.
-
You need Enumerable: If you want to call
.eachor.selectdirectly on the object's attributes. - Legacy Ruby: If your environment is running Ruby version 3.1 or older.
Summary: Why I use Data.define for DTOs
In a typical Rails or Dry-Ruby stack, DTOs are used to move data from a Service Object to a View or from an API response to a Model.
I use Data.define because:
- It prevents accidental side effects (someone changing a value halfway through a request).
- The code is more "honest"—the object is just data.
- The built-in support for keyword arguments makes the code much 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)
Top comments (0)