vendredi 25 novembre 2022
Ce billet a été vu pour la première fois sur le blog de Synbioz le 25 November 2022 sous licence CC BY-NC-SA.

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.