Objekte, die keine ID verdienen: Verwendung von Rails composed_of

Von
/29.07.25
Rails composed_of
Bild von Jackson Simmer via Unsplash
In der realen Welt (und im Code) gibt es Entitäten, die irgendwo zwischen einem einfachen Skalar und einem vollwertigen Objekt liegen. Was bedeuten Dinge wie 80 Kilogramm, 25 Kilometer pro Stunde oder 25 × 25 × 50 cm eigentlich? Sie sind offensichtlich keine Skalare im engeren Sinne – sie lassen sich nicht auf einen einzelnen Wert wie 25 oder «Buenos Aires» reduzieren. Aber sie teilen auch ein zentrales Merkmal mit Skalaren: Sie existieren nicht unabhängig in der Realität. 80 Kilogramm beschreiben das Gewicht von etwas, genauso wie 25 km/h die Geschwindigkeit von etwas darstellen. Dimensionen beschreiben eine Kiste, ein Möbelstück oder ein Klavier – bedeuten aber für sich allein wenig.
Deshalb lassen sich diese Entitäten schwer kategorisieren. Wenn wir sie im Code modellieren, ist es leicht, in unbeholfene oder unvollständige Darstellungen zu verfallen. Zum Glück bietet uns Rails ein leistungsstarkes Werkzeug: die Klassenmethode composed_of, mit der sich solche Werte präzise und ausdrucksstark modellieren lassen.

Wann sollte man composed_of verwenden?

Es gibt zwei ziemlich intuitive Hinweise darauf, dass man composed_of verwenden sollte. Der erste ist, wenn du eine Gruppe von Model-Attributen bemerkst, die immer gemeinsam verwendet werden und im Grunde eine eigene konzeptuelle Einheit bilden. Zum Beispiel, wenn dein Hotel-Modell die Attribute latitude und longitude besitzt und du diese immer als Einheit behandelst: eine geografische Koordinate.
Ein gängiger, aber fehleranfälliger Ansatz:
  class Hotel < ApplicationRecord
  def coordinates = [latitude, longitude]

  def distance_to(other_hotel)
    lat1, lon1 = latitude, longitude
    lat2, lon2 = other_hotel.latitude, other_hotel.longitude

    rad_per_deg = Math::PI / 180
    r_km = 6371 # mittlerer Erdradius in km
    dlat_rad = (lat2 - lat1) * rad_per_deg
    dlon_rad = (lon2 - lon1) * rad_per_deg
    lat1_rad, lat2_rad = lat1 * rad_per_deg, lat2 * rad_per_deg

    a = Math.sin(dlat_rad / 2)**2 +
        Math.cos(lat1_rad) * Math.cos(lat2_rad) * Math.sin(dlon_rad / 2)**2
    c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
    r_km * c
  end
end
Das funktioniert, aber es fehlt an semantischer Klarheit. Es wird nicht explizit modelliert, dass latitude und longitude ein einziges Konzept bilden. Zudem werden hilfreiche Verhaltensweisen wie Validierung oder Distanzberechnung nicht gekapselt. Um das zu verbessern, könnte man zwei alternative Wege einschlagen – beide besser, aber nicht ideal.

Ein neues Modell erstellen

Ein Ansatz besteht darin, ein separates Coordinate-Modell zu erstellen, das dieses Verhalten kapselt. Damit lassen sich Validierungen leicht umsetzen, und es entsteht mehr Klarheit:
  class Coordinate < ApplicationRecord
  EARTH_RADIUS_KM = 6371

  belongs_to :locatable, polymorphic: true

  validates :latitude, presence: true,
            numericality: { greater_than_or_equal_to: -90, less_than_or_equal_to: 90 }
  validates :longitude, presence: true,
            numericality: { greater_than_or_equal_to: -180, less_than_or_equal_to: 180 }

  def distance_to(other_coordinate)
    rad_per_deg = Math::PI / 180
    dlat_rad = (other_coordinate.latitude - latitude) * rad_per_deg
    dlon_rad = (other_coordinate.longitude - longitude) * rad_per_deg

    lat1_rad = latitude * rad_per_deg
    lat2_rad = other_coordinate.latitude * rad_per_deg

    a = Math.sin(dlat_rad / 2)**2 +
        Math.cos(lat1_rad) * Math.cos(lat2_rad) * Math.sin(dlon_rad / 2)**2
    c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))

    EARTH_RADIUS_KM * c
  end

  def to_a = [latitude, longitude]
end
Das ist eine deutliche Verbesserung, bringt aber zusätzliche Komplexität mit sich. Jedes Mal, wenn du ein Hotel lädst, musst du auch den zugehörigen Koordinaten-Datensatz laden – sonst riskierst du N+1-Abfragen.

Verwendung von Concerns

Eine weitere Möglichkeit besteht darin, ein Modul (ein ActiveSupport::Concern) zu definieren, das die Coordinate-Logik direkt innerhalb des Hotel-Modells kapselt:
  module Geocodable
  extend ActiveSupport::Concern

  EARTH_RADIUS_KM = 6371

  included do
    validates :latitude, presence: true,
              numericality: { greater_than_or_equal_to: -90, less_than_or_equal_to: 90 }
    validates :longitude, presence: true,
              numericality: { greater_than_or_equal_to: -180, less_than_or_equal_to: 180 }
  end

  def coordinates = [latitude, longitude]

  def distance_to(other)
    ...
  end
end
Das ist gegenüber einer separaten Tabelle ein Fortschritt. Es ermöglicht Validierungen und erlaubt dir, beliebige geokodierbare Objekte zu vergleichen – egal ob es sich um Hotels oder Freizeitparks handelt. Aber die Koordinaten bleiben zwei lose Attribute. Es gibt keine Unveränderlichkeit (Immutability), und nichts hindert dich daran, nur einen der beiden Werte zu aktualisieren – was keinen Sinn ergibt bei etwas, das einen einzigen Punkt im Raum darstellen soll.
Dieser Ansatz leidet zudem unter Namensraum-Verschmutzung: Methoden wie distance_to befinden sich direkt neben fachlichen Methoden wie hotel.name oder hotel.check_availability. Es wird schwieriger, zu erkennen, was zur Domäne des Hotels gehört und was zur Standortlogik. Und da die Methoden verstreut sind, geht das Gefühl verloren, dass die Koordinaten ein zusammenhängendes Konzept darstellen.

Die elegante Lösung: composed_of

Zum Glück bietet uns Rails eine saubere und ausdrucksstarke Möglichkeit, Value Objects mit mehreren Attributen zu modellieren – ohne zusätzliche Tabellen oder Probleme mit dem Namensraum: composed_of.
  class Coordinate
  include ActiveModel::Validations

  attr_reader :latitude, :longitude

  EARTH_RADIUS_KM = 6371

  validates :latitude, presence: true,
            numericality: { greater_than_or_equal_to: -90, less_than_or_equal_to: 90 }
  validates :longitude, presence: true,
            numericality: { greater_than_or_equal_to: -180, less_than_or_equal_to: 180 }

  def initialize(latitude, longitude)
    @latitude = latitude
    @longitude = longitude
  end

  def distance_to(other)
    rad_per_deg = Math::PI / 180
    dlat_rad = (other.latitude - latitude) * rad_per_deg
    dlon_rad = (other.longitude - longitude) * rad_per_deg

    lat1_rad = latitude * rad_per_deg
    lat2_rad = other.latitude * rad_per_deg

    a = Math.sin(dlat_rad / 2)**2 +
        Math.cos(lat1_rad) * Math.cos(lat2_rad) * Math.sin(dlon_rad / 2)**2
    c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))

    EARTH_RADIUS_KM * c
  end
end

class Hotel < ApplicationRecord
  composed_of :coordinates,
              class_name: 'Coordinate',
              mapping: [%w(latitude latitude), %w(longitude longitude)],
              allow_nil: true,
              converter: Proc.new { |value|
                case value
                when Coordinate
                  value
                when [latitude, longitude]
                  Coordinate.new(latitude, longitude)
                when { latitude:, longitude: }
                  Coordinate.new(latitude, longitude)
                else
                  raise ArgumentError, "Cannot convert #{value.inspect} to Coordinate"
                end
              }

  validate :coordinates_must_be_valid

  def coordinates_must_be_valid
    return unless coordinates.present?
    return if coordinates.valid?

    coordinates.errors.full_messages.each do |message|
      errors.add(:coordinates, message)
    end
  end
end

Beispiele

  hotel1 = Hotel.create!(name: "Hilton", coordinates: [40.7128, -74.0060])
hotel2 = Hotel.create!(name: "Marriott", coordinates: { latitude: 40.7580, longitude: -73.9855 })

distance = hotel1.coordinates.distance_to(hotel2.coordinates) # => 7.3 km

Vorteile

Dieser Ansatz hat mehrere Vorteile:
  • Klare Modellierung: Du machst explizit, dass latitude und longitude gemeinsam ein Konzept darstellen.
  • Wiederverwendbarkeit: Das Coordinate-Objekt ist wiederverwendbar, testbar und kapselt Logik wie Distanzberechnung und Validierung.
  • Validation: Du kannst Validierungen direkt in das Value Object integrieren, und Hotel kann überprüfen, ob seine Koordinaten gültig sind.
  • Keine zusätzliche Datenbanktabelle nötig: Du bekommst alle Vorteile eines Objekts ohne die Komplexität einer eigenen Tabelle.
  • Keine Namensraum-Verschmutzung: Standortlogik bleibt in der Coordinate-Klasse, Geschäftslogik im Modell.

Fazit

Wenn du zwei oder mehr Attribute hast, die gemeinsam ein Konzept beschreiben, aber keine eigene Datenbank-ID oder -Tabelle benötigen, ist composed_of ein eleganter Weg, sie in ein Value Object zu verpacken. Es macht deinen Code nicht nur ausdrucksstärker und wartbarer, sondern auch robuster gegenüber semantischen Fehlern.