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.