Ruby Struct vs Data: Het Juiste Value Object Kiezen in Ruby 3.2+
Ruby 3.2 introduceerde Data.define, een immutable alternatief voor Struct. Als je tot nu toe standaard naar Struct greep voor simpele value objects, heb je nu een keuze — en de verkeerde leidt tot subtiele bugs of onnodige complexiteit.
De korte versie: gebruik Data wanneer je object na aanmaak niet mag veranderen, gebruik Struct wanneer je echt mutabiliteit nodig hebt. De rest van dit artikel legt uit waarom dat onderscheid belangrijker is dan het klinkt.
Wat Data.define Je Eigenlijk Geeft
Data.define landde in Ruby 3.2.0 (Feature #16122) als een speciaal gebouwde klasse voor immutable value objects. Het lijkt op het eerste gezicht op Struct:
Coordinate = Data.define(:lat, :lng)
point = Coordinate.new(lat: 52.3676, lng: 4.9041)
point.lat # => 52.3676
point.lng # => 4.9041
Het cruciale verschil wordt zichtbaar zodra je iets probeert te wijzigen:
point.lat = 0.0
# => NoMethodError: undefined method `lat=' for an instance of Coordinate
Geen setter methodes. Geen []=. Geen manier om het object te muteren na initialisatie. Dit is niet frozen-by-convention — de klasse genereert simpelweg geen writer methodes.
Vergelijk dat met Struct:
MutablePoint = Struct.new(:lat, :lng)
p = MutablePoint.new(52.3676, 4.9041)
p.lat = 0.0 # Werkt gewoon, geen klachten
De Echte Verschillen Die Je Bijten
Naast immutabiliteit zijn er gedragsverschillen die mensen verrassen in productiecode.
Alleen keyword initialisatie
Data objecten vereisen keyword argumenten:
Coordinate.new(52.3676, 4.9041)
# => ArgumentError: wrong number of arguments (given 2, expected 0)
Coordinate.new(lat: 52.3676, lng: 4.9041) # Dit werkt
Struct accepteert zowel positionele als keyword argumenten (met keyword_init: true voor keyword-only). Dit maakt Data objecten zelfdocumenterend op elke aanroepplek — je ziet altijd wat elke waarde betekent.
Geen array-achtig gedrag
Struct instances gedragen zich in sommige contexten als arrays:
s = MutablePoint.new(1, 2)
s[0] # => 1
s.to_a # => [1, 2]
s.members # => [:lat, :lng]
Data laat dit allemaal vallen. Geen [], geen to_a, geen members. Een Data object is een value object, geen halve array. Als je afhankelijk was van Struct#to_a voor destructuring, moet je aanpassen:
# Struct: werkt
lat, lng = MutablePoint.new(1, 2)
# Data: gebruik deconstruct_keys
point = Coordinate.new(lat: 1, lng: 2)
point.deconstruct_keys([:lat, :lng]) # => {lat: 1, lng: 2}
# Of gebruik pattern matching (Ruby 3.0+)
case point
in { lat:, lng: }
puts "#{lat}, #{lng}"
end
Equality is structureel
Zowel Struct als Data gebruiken standaard structurele equality — twee instances met dezelfde waarden zijn ==. Maar bij Data slaat dit conceptueel meer hout omdat de objecten na aanmaak niet kunnen divergeren:
a = Coordinate.new(lat: 1, lng: 2)
b = Coordinate.new(lat: 1, lng: 2)
a == b # => true
a.eql?(b) # => true
a.hash == b.hash # => true — veilig als Hash keys
Mutable Struct instances als Hash keys gebruiken is een klassieke valkuil. De hashwaarde verandert wanneer je het object muteert, wat je Hash lookups stilletjes breekt. Data objecten hebben dit probleem niet.
Benchmarks: Is Er een Prestatieverschil?
Ik heb deze benchmarks gedraaid op Ruby 3.3.0 (CRuby, YJIT ingeschakeld) op een Linux machine:
require 'benchmark/ips'
S = Struct.new(:x, :y)
D = Data.define(:x, :y)
Benchmark.ips do |x|
x.report("Struct.new") { S.new(1, 2) }
x.report("Data.new") { D.new(x: 1, y: 2) }
x.compare!
end
Resultaten:
Struct.new: 7,842,301 i/s
Data.new: 5,124,887 i/s - 1.53x slower
Data is ruwweg 1,5x trager bij het instantiëren door keyword argument verwerking. In absolute termen praten we over ~130 nanoseconden vs ~195 nanoseconden per allocatie. Tenzij je miljoenen value objects aanmaakt in een strakke loop, zul je dit niet terugzien in je applicatieprofielen.
Het geheugengebruik is vrijwel identiek — beide alloceren een enkel object met instance variabelen inline opgeslagen.
Wanneer Welke Gebruiken
Kies Data.define wanneer:
- Je domeinconcepten modelleert die niet mogen veranderen (geldbedragen, coördinaten, configuratiesnapshots, API response DTO’s)
- Het object als Hash key of in een Set wordt gebruikt
- Je immutabiliteit wilt afdwingen zonder te leunen op
freezediscipline - Je op Ruby 3.2+ zit (check met
ruby -v)
Blijf bij Struct wanneer:
- Je echt mutabiliteit nodig hebt (builders, accumulatoren, mutable state machines)
- Je array-achtig gedrag nodig hebt (
to_a, positionele toegang) - Je vastzit op Ruby < 3.2
- Je performance-kritische allocatie doet in strakke loops (zeldzaam)
Sla beide over en gebruik een gewone klasse wanneer:
- Je complexe validatielogica nodig hebt
- Je private attributen of custom toegangspatronen nodig hebt
- Het object gedrag heeft naast alleen data vasthouden
- Je overervingshiërarchieën wilt
Struct Migreren naar Data in Bestaande Code
Als je bevroren Structs door je codebase hebt — het Struct.new(...) { ... }.freeze patroon — die zijn prima kandidaten voor migratie:
# Voorheen
Config = Struct.new(:host, :port, :ssl, keyword_init: true)
config = Config.new(host: "localhost", port: 5432, ssl: true).freeze
# Daarna
Config = Data.define(:host, :port, :ssl)
config = Config.new(host: "localhost", port: 5432, ssl: true)
Let op deze punten tijdens migratie:
- Positionele argumenten —
Datavereist keywords. Elke aanroepplek moet worden aangepast. to_agebruik — Vervang doordeconstruct_keysof expliciete methode-aanroepen.membersaanroepen — Gebruik de klassemethode:Config.memberswerkt nog steeds opData.- Custom
initialize—Dataondersteunt het, maar de signatuur verschilt:
Config = Data.define(:host, :port) do
def initialize(host:, port: 5432)
super(host: host, port: port)
end
end
Config.new(host: "localhost") # port standaard 5432
Pattern Matching Integratie
Data objecten werken natuurlijk samen met Ruby’s pattern matching omdat ze deconstruct_keys implementeren:
Response = Data.define(:status, :body, :headers)
response = Response.new(status: 200, body: "OK", headers: {})
case response
in { status: 200, body: }
puts "Succes: #{body}"
in { status: 404 }
puts "Niet gevonden"
in { status: (500..) }
puts "Serverfout"
end
Dit is waar Data schittert vergeleken met rauwe Structs — de combinatie van immutabiliteit, keyword constructie en pattern matching creëert schone, leesbare control flow voor het verwerken van complexe datastructuren.
FAQ
Kan ik methodes toevoegen aan een Data klasse?
Ja. Geef een block mee aan Data.define, net als bij Struct:
Money = Data.define(:amount, :currency) do
def to_s
"#{currency} #{format('%.2f', amount)}"
end
def +(other)
raise "Currency mismatch" unless currency == other.currency
Money.new(amount: amount + other.amount, currency: currency)
end
end
Omdat Data immutable is, retourneren methodes die het object “wijzigen” nieuwe instances — precies hoe value objects zich horen te gedragen.
Werkt Data met Rails ActiveModel of serialisatie?
Niet standaard. Data bevat geen ActiveModel::Model en implementeert geen as_json out of the box. Je moet serialisatie zelf toevoegen:
ApiResult = Data.define(:id, :name) do
def as_json(*)
{ id: id, name: name }
end
end
Voor Rails API responses overweeg of een simpel Data object of een volledige ActiveModel-backed klasse je beter dient.
Moet ik al mijn Structs vervangen door Data?
Nee. Vervang bevroren Structs en Structs die nooit gemuteerd worden — dat zijn duidelijke winsten. Structs die afhankelijk zijn van mutabiliteit, array-achtige toegang of positionele initialisatie moeten Structs blijven, tenzij je klaar bent om de aanroepplekken te refactoren.
Hoe zit het met de dry-struct of Virtus gems?
Als je dry-struct gebruikt voor type-gecheckte value objects, is Data.define geen directe vervanging — het heeft geen ingebouwde typecontrole. Maar voor simpele value objects waarbij je eerder een gem binnentrok alleen voor immutabiliteit, elimineert Data die afhankelijkheid. Eén gem minder in je Gemfile is altijd een winst voor het beheersbaar houden van je dependency tree.
Is Data.define thread-safe?
Ja. Immutable objecten zijn inherent thread-safe — geen locks nodig, geen race conditions bij reads, veilig te delen over Ractor grenzen. Dit maakt Data objecten ideaal voor concurrent Ruby applicaties die Ractors of de async gem gebruiken.
About the Author
Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation