The Data Class (define)
Fast recall
Quick revision
Core answer
Data.define creates a class for a value made from a fixed set of members. Point = Data.define(:x, :y) gives instances readers, value-based equality, hashing, inspection, deconstruction, to_h, and with, but no generated writers. All members are required when constructing an instance. It is a strong choice for coordinates, money, or parsed commands whose identity is their contents. The immutability is shallow: a member can still refer to a mutable array or string, so nested values must be frozen or copied when the whole value needs to remain unchanged.Whether you know Data.define (Ruby 3.2+) creates immutable value objects with value equality, and how it differs from Struct.
Trying to mutate a Data object (raises — they're immutable), or using Struct when you want value equality + immutability.
Data.define(:x,:y) (Ruby 3.2+) -> immutable value objects; #== by value. Point.new(x:1,y:2). Use over Struct for immutability + value equality.
Interview-ready guide
Interview answer
Data.define is a Ruby 3.2 feature for declaring a value-object class with named members. The generated class supplies construction, readers, inspection, equality, hashing, conversion to a hash, pattern-matching support, and a with method. It does not generate member writers.
Two instances of the same Data class compare by their member values, so the object represents information rather than a separately changing identity. Every declared member is required. Construction can use positions or keywords, although keyword form usually explains the data more clearly at the call site.
For example, Money = Data.define(:cents, :currency) can represent a price. Money.new(cents: 500, currency: "INR") compares equal to another instance with the same contents and can be used as a hash key. Calling price.with(cents: 650) produces a new Money value while leaving price unchanged.
The important boundary is shallow immutability. Data prevents replacing a member through a generated writer, but it does not recursively freeze an array or string stored in that member. If tags is a mutable array, record.tags << "sale" can still change it unless the program freezes or defensively copies that array.
I choose Data for small, complete values with stable fields and value equality. A Struct is useful when mutable record-style members are intended, while a normal class is better when construction rules, evolving state, hidden representation, or a richer lifecycle are central to the model.
The Data Class (define)
Direct Answer
Data.define creates a class for a value made from a fixed set of members. Point = Data.define(:x, :y) gives instances readers, value-based equality, hashing, inspection, deconstruction, to_h, and with, but no generated writers. All members are required when constructing an instance. It is a strong choice for coordinates, money, or parsed commands whose identity is their contents. The immutability is shallow: a member can still refer to a mutable array or string, so nested values must be frozen or copied when the whole value needs to remain unchanged.
Deep Dive & Architecture
Data.define returns a new class whose members are declared once. Instances expose a reader for each member, but no automatic writer. The constructor expects values for every member and rejects missing or unknown ones. This creates a small explicit shape that is useful at boundaries between parsing, validation, and domain behaviour.
Equality follows the value model. Two objects compare equal when they belong to the same generated Data class and their corresponding members compare equal. Compatible eql? and hash behaviour makes such values suitable as hash keys, provided the member values used for hashing do not change underneath them. Identity with equal? remains a separate concept: equal values may still be different objects.
The with method supports change without a writer. It builds a shallow copy using the current members and replaces only the supplied keywords. This is convenient for transformations such as changing a coordinate or currency code while preserving the original value. to_h exposes the named members, and generated deconstruct and deconstruct_keys methods let the object work directly with Ruby pattern matching.
No writer is not the same as deep freezing. If a Data member holds ['new'], the instance cannot point its tags member at a different array through tags=, but callers can still append to the original array. A robust value object accepts already immutable values, freezes a defensive copy during construction, or documents that contained objects remain shared. The right choice depends on ownership and performance needs.
A block passed to Data.define can add behaviour. That is useful for operations derived entirely from the members, such as adding two coordinates or formatting a currency value. It should not quietly introduce setters that defeat the model. Input validation may live in a custom initialize, a factory, or a boundary before construction, but the design should preserve the promise that a constructed value is complete.
Data, Struct, and a hand-written class solve related but distinct problems. Struct offers concise record classes and can expose writable members. Data emphasizes fixed value state. A normal class can hide its representation, accept partial construction, enforce transitions, or use identity-based persistence. Selecting among them starts with the domain contract, not with which option saves the most lines.
Code Example
Money = Data.define(:cents, :currency) do
def format
"#{currency} #{cents / 100.0}"
end
end
price = Money.new(cents: 500, currency: 'INR')
same_price = Money.new(500, 'INR')
revised = price.with(cents: 650)
puts(price == same_price) # true
puts price.format # INR 5.0
puts revised.format # INR 6.5
p price.to_h # { cents: 500, currency: 'INR' }
Common Mistakes & Pitfalls
Trying to mutate a Data object (raises — they're immutable), or using Struct when you want value equality + immutability.
Check your understanding
Answer these without looking back at the article.
- Why can a Data instance still change indirectly through a mutable member?
- How does `with` differ from assigning through a setter?
- When is a regular class a better model than `Data.define`?
Looking for a structured roadmap?
Every module on this page is also part of the full Java backend interview track.