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.