How I Use Ruby's Struct and Data Classes in Production

by Arif Ikhsanudin, Backend Developer

The gap they fill

Plain hashes are the default for structured data in Ruby. They work until they don't — until you're passing { user_id: 1, role: :admin, expires_at: Time.now } through four method calls and nobody can tell from the signature what keys are expected, which are optional, or what shape the data is supposed to have.

A Hash has no schema. A Struct does. That's the entire argument.

The choice between Struct and Data (introduced in Ruby 3.2) comes down to one question: does the container need to be mutated after creation? If no, use Data. If yes, use Struct. Most of the time the answer is no, but Struct accumulated enough useful behavior over the years that it's still the right tool in specific situations.

Struct — the mutable workhorse

Struct.new generates a class with accessors, a constructor, ==, to_s, to_h, and members. It's been in the stdlib since Ruby 1.0 and the API is completely stable:

Point = Struct.new(:x, :y)

p = Point.new(3, 4)
p.x        # => 3
p.x = 10   # mutates in place — intentional
p.to_h     # => { x: 10, y: 4 }
p == Point.new(10, 4)  # => true — structural equality

The keyword_init: true option (Ruby 2.5+) switches to keyword arguments, which I use for anything with more than two members — positional arguments become ambiguous fast:

WebhookPayload = Struct.new(:event_type, :resource_id, :occurred_at, keyword_init: true)

payload = WebhookPayload.new(
  event_type:   "order.completed",
  resource_id:  "ord_abc123",
  occurred_at:  Time.now
)

Adding methods to a Struct

The block form lets you define methods on the generated class without subclassing:

LineItem = Struct.new(:unit_price, :quantity, keyword_init: true) do
  def total
    unit_price * quantity
  end

  def discounted_total(rate)
    total * (1 - rate)
  end
end

item = LineItem.new(unit_price: 12_00, quantity: 3)
item.total  # => 3600

This is a significant part of Struct's value in production. You get a schema plus behavior, without the boilerplate of a full class definition. I use this for internal result-like objects that carry data and need a few derived values.

Where Struct's mutability creates problems

Struct members are mutable by default and that mutable interface is part of the generated class's API. If you freeze the instance, assignment raises. If you don't, callers can modify members they shouldn't touch:

config = AppConfig.new(timeout: 30, retries: 3)
config.timeout = 0  # nothing stops this

The fix is to freeze in the constructor:

AppConfig = Struct.new(:timeout, :retries, keyword_init: true) do
  def initialize(*)
    super
    freeze
  end
end

At that point you've manually replicated what Data gives you for free. If the object is never meant to be mutated, start with Data instead.

Struct equality and hash keys

Struct implements == structurally, but eql? and hash are also implemented correctly — two structs of the same class with the same values have the same hash. This means structs work reliably as hash keys and in sets, without any extra implementation on your part:

cache = {}
key1 = Point.new(1, 2)
key2 = Point.new(1, 2)

cache[key1] = "stored"
cache[key2]  # => "stored"

This is one place Struct beats a hand-rolled class where you've forgotten to define hash.

Data — immutable by default

Data.define (Ruby 3.2+) generates a class with keyword constructors, structural equality, and freeze called automatically on every instance. No setters are generated:

Coordinate = Data.define(:latitude, :longitude)

point = Coordinate.new(latitude: 51.5, longitude: -0.1)
point.frozen?    # => true
point.latitude = 52.0  # => NoMethodError

The constructor is keyword-only. There's no positional form. That's deliberate — Data is designed for named, structured values where member order shouldn't matter at the call site.

Validation and derived values in Data

Like Struct, Data.define accepts a block:

EmailAddress = Data.define(:address) do
  def initialize(address:)
    raise ArgumentError, "invalid email" unless address.match?(URI::MailTo::EMAIL_REGEXP)
    super(address: address.downcase)
  end

  def domain
    address.split("@").last
  end
end

email = EmailAddress.new(address: "Alice@Example.com")
email.address  # => "alice@example.com"
email.domain   # => "example.com"
email.frozen?  # => true

The initialize override must call super with keyword arguments before the object is frozen. Validation here means the object can never exist in an invalid state — the constructor raises before freeze is called. This is the key advantage over a Struct with a manual freeze: the invariant is enforced at the language level, not by convention.

with — non-destructive updates

Data instances are immutable, but you frequently need to produce modified versions. The with method handles this:

base = Coordinate.new(latitude: 51.5, longitude: -0.1)
shifted = base.with(latitude: 52.0)

shifted  # => #<data Coordinate latitude=52.0, longitude=-0.1>
base     # unchanged

with returns a new instance with the specified attributes replaced and the rest copied. It's the functional update pattern without any boilerplate. This is how you handle "update one field" without mutation.

Production use cases by type

Use Struct for:

Internal result containers that accumulate state across steps. A multi-stage pipeline where each step writes results onto a shared container before the next stage reads them:

PipelineContext = Struct.new(:raw_input, :parsed, :validated, :output, keyword_init: true)

context = PipelineContext.new(raw_input: payload)
context.parsed    = ParseStep.call(context.raw_input)
context.validated = ValidateStep.call(context.parsed)
context.output    = TransformStep.call(context.validated)

Mutable here is intentional — the struct is a working surface, not a value being passed around.

Use Data for:

Configuration objects, domain value types, API response shapes, event payloads — anything that's created once and read many times:

RateLimitConfig = Data.define(:requests_per_minute, :burst_limit, :key_prefix) do
  def initialize(requests_per_minute:, burst_limit:, key_prefix: "rl")
    raise ArgumentError, "requests_per_minute must be positive" unless requests_per_minute > 0
    super
  end

  def window_seconds
    60
  end
end

RATE_LIMIT = RateLimitConfig.new(requests_per_minute: 100, burst_limit: 150).freeze

The freeze on RATE_LIMIT is redundant — Data instances are already frozen — but it reads as documentation of intent.

The anonymous Struct antipattern

One thing to avoid: anonymous Structs assigned to local variables:

# Don't do this
result = Struct.new(:user, :token).new(current_user, generate_token)

Anonymous Structs have an awkward to_s (it includes the full member list), don't have a meaningful class name for error messages, and can't be reused or referenced in tests. If a Struct is worth creating, it's worth naming. Assign it to a constant.

Choosing between them

The decision is straightforward in practice. If the object's members need to change after creation — because it's a working accumulator, a builder, or an object that receives updates over its lifetime — use Struct. If the object represents a fact, a configuration, a measurement, or a result that's complete at construction — use Data on Ruby 3.2+, or a manually frozen Struct with keyword_init: true on earlier versions.

Both are underused relative to plain hashes. Any time you're passing a hash through more than two method calls, the hash probably has a name waiting to be given to it.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Your Docker Compose File Is Messier Than It Needs to Be

Docker Compose files accumulate complexity as projects grow — hardcoded values, duplicated configuration, missing health checks, and environment-specific hacks that never get cleaned up. A structured approach keeps them maintainable.

Read more

Scaling Your Freelance Business: From Solo to Small Agency

Going from one-person freelancer to running a small agency sounds exciting—and terrifying. Suddenly, your work isn’t just about your skills; it’s about systems, people, and growth.

Read more

Trunk Based Development vs Gitflow: Which One Should Your Team Use

Gitflow solves a release coordination problem. Trunk-based development solves a integration speed problem. Which one your team needs depends entirely on your release model — not your company size.

Read more

What Actually Happens Inside a Database Transaction

Transactions provide atomicity and isolation, but understanding how they work mechanically — through write-ahead logs, lock acquisition, and MVCC snapshots — explains why certain patterns cause deadlocks, bloat, and unexpected performance cliffs.

Read more