35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English

Ruby Struct vs Data: Het Juiste Value Object Kiezen in Ruby 3.2+

roger
Een praktische vergelijking van Ruby's Struct en Data klassen voor het bouwen van value objects. Benchmarks, use cases en migratiepatronen voor Ruby 3.2+ projecten.

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 freeze discipline
  • 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:

  1. Positionele argumentenData vereist keywords. Elke aanroepplek moet worden aangepast.
  2. to_a gebruik — Vervang door deconstruct_keys of expliciete methode-aanroepen.
  3. members aanroepen — Gebruik de klassemethode: Config.members werkt nog steeds op Data.
  4. Custom initializeData ondersteunt 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.

#ruby #value-objects #ruby-3.2 #struct #data #immutability
r

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 Touch

Share this article

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