Rails et les value objects
Les entrailles d’un framework cachent parfois des bouts de code fort
intéressants ! C’est le cas de la méthode composed_of
du module
ActiveRecord::Aggregations
qui par plusieurs aspects va nous intéresser
aujourd’hui : elle nous permet d’introduire une notion importante d’architecture
logiciel, les value objects ; et de revenir sur 10 ans de rebondissements
autour de cette méthode ! Sortez les popcorns 🍿
Une vie mouvementée
Nous sommes en juin 2012, Rails arbore fièrement sa version 3.2 ! Et dans un
post, faisant suite à une PR de Steve
Klabnik, Rafael França nous explique
pourquoi composed_of
sera prochainement déprécié, puis retiré à compter de la
version 4.0 du framework.
Les raisons sont une complexité superflue pour une méthode rarement utilisée qu’on pourrait qualifier de cosmétique (nous y reviendrons), et de multiples bugs relatifs à cette méthode dans le framework à l’époque.
Seulement, tout ne se passa pas comme prévu, et deux mois plus tard, en août 2012…
We have decided to stop introducing API deprecations in all point releases going forward. From now on, it’ll only happen in majors/minors.
— @Rails, Twitter, 1er août 2012
La décision fut alors prise de réintroduire cette méthode, toujours présente à ce jour dans la version 7.0 de Rails ! Cette méthode et la documentation qui lui est associée reçoivent d’ailleurs toujours des améliorations, comme le montre cette PR de Neil Carvalho datant de septembre 2022.
Mais alors, à quoi peut bien servir cette méthode méconnue qui a bien failli disparaitre ?
Déclarez vos objets de valeur
La méthode composed_of
du module ActiveRecord::Aggregations
permet de
manipuler des value objects, c’est-à-dire des objets ayant pour seule vocation
que de véhiculer une valeur. Un value object a la particularité d’être
identifiable par la valeur qu’il véhicule et non pas par un identifiant. En
d’autres termes, deux value objects sont égaux s’ils représentent la même
valeur. Autre condition nécessaire, un value object se doit d’être
immuable. La notion de value object est
très présente dans la littérature portant sur le Domain Driven Design. Un
excellent article de Victor Savkin fait
d’ailleurs le lien entre Rails et DDD.
Un cas d’usage
Prenons un exemple qui parlera à tout le monde : la manipulation de valeurs
monétaires. Il arrive assez fréquemment que l’on ait à manipuler des montants et
des devises, que ce soit dans le cadre d’une application e-commerce, ou tout
simplement l’établissement d’une facture. Dans ce cas, nous avons pris
l’habitude de stocker en base, dans deux champs distincts mais étroitement liés,
ce montant (appelons-le amount
) et la devise associée (nommons-la currency
).
Un value object nous permettra ici de manipuler ces deux informations au sein d’une même représentation. Nous pourrions imaginer la chose comme ceci, par exemple :
class Money
attr_reader :amount, :currency
def initialize(amount, currency = "EUR")
@amount = amount
@currency = currency
end
end
Nous avons là un objet Money
qui nous permet de manipuler des valeurs
monétaires, et nous assure de toujours conserver ce lien entre montant et
devise, l’un n’allant pas sans l’autre d’un point de vue fonctionnel. Seulement,
il nous manque un petit quelque chose pour en faire un value object : nous
avons besoin de définir l’égalité entre deux objets de cette classe !
class Money
include Comparable
# …
def ==(other_money)
amount == other_money.amount && currency == other_money.currency
end
end
Grace au module Comparable
que l’on vient d’inclure, et à la méthode ==
,
nous voici en mesure de comparer deux objets de la classe Money
:
irb(main)> Money.new(5, "EUR") == Money.new(5, "EUR")
=> true
irb(main)> Money.new(5, "EUR") != Money.new(5, "USD")
=> true
Mais un value object ne se limite pas forcément à l’encapsulation d’une ou plusieurs valeurs, il peut aussi présenter un ensemble de méthodes qui lui sont propres ! Ici nous pourrions par exemple souhaiter convertir un montant dans une autre devise, ou encore comparer deux montants déclarés dans des devises différentes.
class Money
EXCHANGE_RATES = { "EUR_TO_JPY" => 146 }
# …
def exchange_to(other_currency)
exchanged_amount = (amount * EXCHANGE_RATES["#{currency}_TO_#{other_currency}"]).floor
Money.new(exchanged_amount, other_currency)
end
def <=>(other_money)
if currency == other_money.currency
amount <=> other_money.amount
else
amount <=> other_money.exchange_to(currency).amount
end
end
end
Notons que notre objet est immuable, la méthode exchange_to
retourne donc une
nouvelle instance de notre classe Money
.
irb(main)> Money.new(5, "EUR") == Money.new(5, "EUR")
=> true
irb(main)> Money.new(5, "EUR").exchange_to("JPY")
=> #<Money:0x00007faee7162f68 @amount=730, @currency="JPY">
irb(main)> Money.new(5, "EUR") == Money.new(730, "JPY")
=> true
irb(main)> Money.new(5, "EUR") > Money.new(500, "JPY")
=> true
Et composed_of dans tout ça ?
La méthode de classe composed_of
appliquée sur un modèle ActiveRecord
nous
permet de lier les attributs de celui-ci pour les manipuler sous la forme d’un
value object. Voici un exemple d’utilisation de notre classe Money
:
# == Schema Information
#
# Table name: invoices
#
# id :integer not null, primary key
# total_amount :decimal(, )
# total_currency :string
class Invoice < ActiveRecord::Base
composed_of :total,
class_name: "Money",
mapping: { total_amount: :amount, total_currency: :currency }
end
Ainsi, nous pouvons directement utiliser une instance de la classe Money
à
travers l’attribut total
, et ce en lecture comme en écriture !
irb(main)> invoice = Invoice.new(total: Money.new(5, "EUR"))
=> #<Invoice id: nil, total_amount: 0.5e1, total_currency: "EUR">
irb(main)> invoice.total
=> #<Money:0x00007f1d1006b038 @amount=5, @currency="EUR">
irb(main)> invoice.total = Money.new(500, "JPY")
=> #<Money:0x000055eca216b658 @amount=500, @currency="JPY">
irb(main)> invoice.total_amounnt
=> 0.5e3
irb(main)> invoice.total_currency
=> "JPY"
Très utile cette méthode, et cela clarifie par la même occasion notre intention ! Notre code s’en trouve plus explicite, et plus facile à comprendre et à maintenir. De plus, nous limitons les responsabilités de notre modèle en cloisonnant dans des value objects les méthodes qui leur sont propres.
Mais alors, pourquoi vouloir la supprimer de Rails ?
Valeur ajoutée & maintenabilité
Tout est dans la mesure. Cette méthode n’est au final qu’un sucre syntaxique, une fonctionnalité cosmétique, et celle-ci a un coût, notamment en termes de maintenabilité pour l’équipe de développement du framework. Ce coût est loin d’être négligeable, à en croire les multiples remontées de bugs qui lui sont imputées, et il convient dans ce cas de peser le pour et le contre afin de choisir entre conserver cette fonctionnalité ou la supprimer.
L’un des arguments de poids à l’encontre de cette méthode, est le fait de devoir lui passer des procs et des hashes pour obtenir magiquement un comportement qui pourrait être décrit de manière bien plus explicite avec un simple objet Ruby. Arrêtons-nous un moment pour prendre deux exemples.
Dans le cas le plus simple, celui d’un attribut unique, nous pourrions nous contenter d’un serializer. Admettons que dans notre exemple précédent, nous ayons choisi de faire fi de la devise. Nous pourrions ainsi écrire ceci :
class MoneySerializer
def dump(money)
money.amount
end
def load(amount)
Money.new(amount)
end
end
class Invoice < ActiveRecord::Base
serialize :total_amount, MoneySerializer.new
end
Autre approche, nous pourrions aussi faire appel à de simples accesseurs, comme ceci par exemple :
class Invoice < ActiveRecord::Base
def total
@total ||= Money.new(total_amount, total_currency)
end
def total=(money)
self[:total_amount] = money.amount
self[:total_currency] = money.currency
@total = money
end
end
Ces deux exemples nous montrent à quel point il est facile d’obtenir le même
résultat, sans la magie de composed_of
, mais surtout avec beaucoup plus de
clarté, j’en veux pour preuve cet exemple tiré de la documentation
d’ActiveRecord :
class NetworkResource < ActiveRecord::Base
composed_of :cidr,
class_name: 'NetAddr::CIDR',
mapping: [ %w(network_address network), %w(cidr_range bits) ],
allow_nil: true,
constructor: Proc.new { |network_address, cidr_range| NetAddr::CIDR.create("#{network_address}/#{cidr_range}") },
converter: Proc.new { |value| NetAddr::CIDR.create(value.is_a?(Array) ? value.join('/') : value) }
end
On comprend rapidement ici que maintenir ce code et le tester sera des plus pénibles !
Ceci étant, dans sa configuration la plus simple, ce petit sucre syntaxique reste attirant à l’œil et, sans convaincre celles et ceux fortement attachés aux principes du Domain Driven Design, devrait séduire les plus Rails-istes d’entre nous — Il suffit de ne pas être trop regardant de ce qu’il y a sous le capot ;)
Petit bonus
Puisque nous parlons d’ActiveRecord, qu’en est-il du requêtage de ces attributs ? Eh bien tout semble se passer le plus intuitivement du monde :
Invoice.where(total: Money.new(42, "EUR"))
Si vous avez choisi de vous passer de composed_of
, il s’agira simplement
d’être explicite là aussi, à l’aide d’une méthode de classe par exemple :
def self.costing(money)
where(total_amount: money.amount, total_currency: money.currency)
end
Le coût d’un code explicite ne semble pas excessif. Surtout au regard des 768 lignes de code nécessaires à cette fonctionnalité cosmétique.
Du discernement
Cet exemple nous montre une nouvelle fois à quel point Rails n’est pas simple ! Il nous faut donc rester sur nos gardes, et prendre la mesure des choix techniques que nous faisons. Aussi insignifiants qu’ils puissent nous paraitre à première vue, leurs répercussions peuvent être considérable avec le temps, en particulier sur la maintenabilité, la pérennité et la testabilité de nos applications.