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

by Eric Hanson, Backend Developer at Clean Systems Consulting

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

The Real Cost of a Senior Backend Hire in Copenhagen — And What Smart Founders Do Instead

You thought a senior backend hire would cost DKK 70K a month. The real number — once Denmark's employer obligations are factored in — is closer to DKK 100K. And that's before the recruiter calls.

Read more

How to Keep Clients Happy When Things Go Wrong

Even the best projects hit bumps. How you handle problems can make or break your client relationships.

Read more

Why Copenhagen Fintech Startups Are Quietly Shifting Backend Work to Async Remote Contractors

Your compliance deadline is in eight weeks. Your backend team is already committed to the payments rewrite. Something has to give — or someone else has to build it.

Read more

Nordic Developer Salaries Are Among the Highest in Europe — Remote Contractors Change the Math

You just lost a backend candidate to Spotify. Not because your product was less interesting — because they offered 10% more and a brand name your recruiter can't compete with. Now you're back to square one with a roadmap that hasn't moved.

Read more