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

Published: (January 14, 2026 at 03:50 PM EST)
3 min read
Source: Dev.to

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 an ArgumentError.

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

FeatureData.define (Ruby 3.2+)Struct
MutabilityImmutable (read‑only)Mutable (read/write)
InitializationPositional or keywordsPositional (keywords must be enabled)
Enumerable methodsNoYes (.each, .map, etc.)
Intended useValue objects / DTOsLightweight “property” classes
Interface sizeMinimal (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.define is 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 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)
Back to Blog

Related posts

Read more »

Create a Telegram bot quote bot in Ruby

Requirements - Must have an IDE - Project structure: project_folder/ ├── bot.rb ├── quote.rb ├── help.rb ├── start.rb └── Gemfile Note: No .env file is committ...