DEV Community

Cover image for Move Over, Struct: Meet Ruby's New Data.define
Zil Norvilis
Zil Norvilis

Posted on

Move Over, Struct: Meet Ruby's New Data.define

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"
Enter fullscreen mode Exit fullscreen mode

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>)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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:

  1. You need mutability: If you are using the object as a temporary "scratchpad" where you update values frequently.
  2. You need Enumerable: If you want to call .each or .select directly on the object's attributes.
  3. 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:

  1. It prevents accidental side effects (someone changing a value halfway through a request).
  2. The code is more "honest"—the object is just data.
  3. The built-in support for keyword arguments makes the code much more readable than positional Struct arguments.

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)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)