Comment préparer une application Ruby On Rails pour DDD ?

Le framework RubyOnRails permet de facilement organiser du code. Cependant au fil du temps et des évolutions, il devient de plus en plus difficile de maintenir le code legacy. Même avec une application Rails.

La démarche DDD (Domain Driven Developement) à pour but d'aider les développeurs dans la maintenance de leur code.

Qu'est-ce que DDD ?

C'est une approche visant à découper la complexité d'une application afin de maintenir sa vélocité et donc son ROI.

En clair, on ne réfléchit plus la conception dans le cadre d'une simple architecture MVC mais sur une couche dite du domaine de l'application. Cette couche du domaine représente ce que "permet de faire" l'application d'un point de vue fonctionnel. Et non plus "comment le faire" avec les contraintes de l'infrastructure.

Une architecture logicielle repose généralement sur plusieurs petits projets. Le premier rassemble le domaine métier de produit. Il propose des Entities, Repositories et UseCases. D'autres projets exploitent ce domaine. Il s'agit généralement d'applications web, de CLI. On parle d'infrastructure.

Concrètement le domaine est habituellement découpé en plusieurs éléments sous forme de classes qui ne doivent exposer qu'une seule responsabilité.

  • Les Entities ont pour but de rassembler des informations. On trouve par exemple des classes s'appellant UserAccount, Project, Task, etc. Il ne faut pas confondre ces classes avec des modèles ActiveRecord. Cette lib est très pratique mais dans une logique DDD/CleanCode cette conception leur donne trop de responsabilité (validation, persistance, comportement, etc.). Pour garantir la maintenance il vaut mieux privilégier des classes ne faisant (ou ne représentant) qu'une chose.

  • Les Repositories permettent d'accéder et de persister les entités. Ils dissimulent la complexité de persistance propre à des techo. tierces.

  • Enfin les UseCases proposent des comportements que devrait permettre l'application. La façon de nommer ces classes est simple. Il s'agit d'utiliser des verbes d'action. Exemple: CreateUserAccount, CreateProjectWithoutOrganisation, NotifySlack, SendWelcomeEmail, etc.. Toute la logique d'une application doit se trouver dans les use cases. On peut aussi les appeler Interactors.

L'avantage de DDD est simple: les développeurs se retrouvent naturellement avec de petites unités logiques qui "disent ce qu'elles font". Elles s'imbriquent entre elles. Ce qui permet de grandement faciliter l'écriture de tests. Donc de renforcer la maintenance de l'application. Chaque composant possède des dépendances qui peuvent alors être mockées. Du moins il suffit de mocker leurs comportements au sein de différents tests. Les dépendances étant de fausses classes.

Comment ça se passe avec Rails ?

Il faut accepter le principe que la couche domaine du code n'a rien à faire dans un projet web. Elle doit être dans un gem spécifique. Pour un projet on se retrouve généralement avec deux repositories. Ce qui peut être déroutant au début mais très pratique par la suite. Pour débuter la démarche DDD, on peut simplement commencer par mettre le code de la couche logique dans lib d'une application Rails avant de créer un gem spécifique.

On va donc se retrouver avec ce genre d'arborescence:

- app/
...
- lib/
  - assets/
  - domain/
  - tasks/

Plus tard, il s'agira d'exporter ce code dans un gem. Puis de l'importer dans le Gemfile. L'ensemble des projets (la partie web et la partie métier) pouvant être indépendants de l'implémentation de l'autre.

Il est nécessaire d'indiquer à Rails de eager loader ces répertoires:

# config/application.rb
# ...
      config.eager_load_paths << Rails.root.join("lib")
# ...

On va prendre l'exemple d'une application permettant de créer/supprimer des flux RSS.

On va d'abord créer l'entité Feed représentant un flux RSS.

# lib/domain/entities/feed.rb
module Entities
  class Feed
    attr_accessor :id,
                  :url,
                  :title
  end
end

Il est important de ne pas confondre Entity avec un modèle ActiveRecord. Une entité sert uniquement à représenter de l'information. Elle sera manipulée par les repositories et les uses cases.

On va créer un premier cas d'utilisation: CreateFeed.

# lib/domain/use_cases/create_feed.rb

module UseCases
  class CreateFeed
    attr_reader :feed_repository, :notifiers

    ValidationSchema = Dry::Validation.Schema do
      required(:url).filled
      required(:url).value(format?: /^http(s?):\/\//)
    end

    def initialize(feed_repository, notifiers = [])
      @feed_repository = feed_repository
      @notifiers = notifiers
    end

    def execute(url:)
      validation_results = ValidationSchema.call(url: url)

      if validation_results.failure?
        return OpenStruct.new(
          success?: false,
          errors: validation_results.errors,
        )
      end

      creation_results = feed_repository.create(url: url, title: nil)

      unless creation_results.success?
        return OpenStruct.new(
          success?: false,
          exception: creation_results.exception,
          errors: creation_results.errors,
        )
      end

      notify(creation_results.feed)

      OpenStruct.new(
        success?: true,
        feed: creation_results.feed,
      )
    end

    protected

    def notify(feed)
      notifiers.each do |notifier|
        notifier.notify("FEED_CREATED", { feed: feed })
      end
    end
  end
end

Que fait cette classe ?

Elle permet de créer un flux RSS. On remarque qu'elle possède deux dépendances: feed_repository et notifiers. Il s'agit de deux comportements qui ne sont pas de la responsabilité du cas d'utilisation. Autrement dit, "la manière de" persister (ou de notifier) ne doit pas être présent dans la classe relative au cas d'utilisation. Mais dans d'autres composants. On arrive avec un code SOLID. le S est très important. (S: Single Responsibility). Dans notre cas, la validation des données devrait aussi être dans une dépendance. Mais j'ai choisi de simplifier.

On va pouvoir créer notre FeedRepository ayant pour rôle d'accéder et de persister des Feed. Dans un application DDD on pourrait avoir plusieurs implémentations différentes. Par exemple, en utilisant Mongodb ou Postgres. Chaque repository doit exposer un comportement qui est le même quelque soit l'implémentation, puis dissimuler la manière dont ces comportements sont réalisés. On va créer un FeedRepository reposant sur ActiveRecord. Ce qui donnerait quelque chose comme ceci:

# lib/domain/repositories/feed_repository/active_record.rb
module Repositories
  # FeedRepository exposes CRUD behaviors of Feed entity
  module FeedRepository
    # Implementation of repository logic with ActiveRecord gem
    class ActiveRecord
      attr_reader :ar_model

      # ar_model must implements methods: save, find, find_by, save!, new, create!, etc.
      def initialize(ar_model)
        @ar_model = ar_model
      end

      def delete(id)
        begin
          feed_record = ar_model.find(id)
          feed_record.destroy!
          OpenStruct.new(success?: true)
        rescue => e
          OpenStruct.new(success?: false, exception: e)
        end
      end

      def list
        feeds = ar_model.order(created_at: :desc).map do |feed_record|
          Entities::Feed.new.tap do |feed|
            feed.id = feed_record.id
            feed.url = feed_record.url
            feed.title = feed_record.title
          end
        end

        OpenStruct.new(success?: true, feeds: feeds)
      end

      def create(url:, title:)
        begin
          feed_record = ar_model.create!(url: url, title: title)

          OpenStruct.new(
            success?: true,
            feed: Entities::Feed.new.tap do |feed|
              feed.id =  feed_record.id
              feed.url = feed_record.url
              feed.title = feed_record.title
            end,
          )
        rescue => e
          OpenStruct.new(
            success?: false,
            exception: e,
            errors: feed_record.errors,
          )
        end
      end
    end
  end
end

Enfin on va pouvoir utiliser ce domaine dans notre application web et spécifiquement dans un contrôleur FeedsController.

Il faut d'abord initialiser nos dépendances. On pourrait le faire de la manière suivante.

# lib/domain/domain.rb
module Domain
  def self.feed_repository
    @feed_repository ||= Repositories::FeedRepository::ActiveRecord.new(Feed)
  end

  def self.create_feed
    @create_feed ||= UseCases::CreateFeed.new(
      feed_repository,
      notifiers = [
        slack_notifier,
      ],
    )
  end

  def self.slack_notifier
    @slack_notifier ||= Notifiers::SlackNotifier.new
  end
end

Puis dans le contrôleur:

class FeedsController < ApplicationController
  def index
    results = Domain.feed_repository.list
    unless results.success?
      Rails.logger.info("Cannot list feeds: #{result.exception}")
      return
    end

    @feeds = results.feeds
  end

  def new
    @feed = Feed.new
  end

  def create
    results = Domain.create_feed.execute(url: feed_params[:url])

    if results.success?
      # notify_slack # Bad, it's not his responsibility
      redirect_to :feeds
      return
    end

    @form_errors = results.errors
    @feed = Feed.new(feed_params)

    render :new
  end

  def destroy
    results = Domain.feed_repository.delete(params[:id])
    if results.success?
      redirect_to :feeds
      return
    end

    redirect_to :feeds, notice: "Cannot delete feed"
  end

  protected

  def feed_params
    params.require(:feed).permit(:url)
  end
end

Intéret ?

L'énorme avantage de cette approche se retrouve dans la maintenance du code. Par exemple, si votre application permet à vos utilisateurs de se créer un compte, et que vous avez besoin de modifier profondément la manière de la faire techniquement, alors vous n'avez qu'à créer un use case RegisterUserV2 qui va proposer une nouvelle implémentation mais présenter le même comportement que l'autre use case. Cette manière de faire permet de corriger/améliorer les choses par l'ajout de classes et non de simples modifications du code pouvant générer des bugs.

Autre cas de figure, imaginons que vous souhaitez que votre app. envoie un email de bienvenue lorsqu'un utilisateur se crée un compte. Avec une approche DDD il faut partir du principe que l'envoi d'emails n'est pas de la responsabilité du contrôleur. Mais de la couche applicative. En effet c'est un besoin métier. Du coup, vous n'avez qu'à mettre en place un interactor/use_cases SendWelcomeEmail. Si demain vous souhaitez aussi envoyer des notifications par Slack par exemple, vous n'avez qu'à implémenter la manière de le faire dans un nouvel interactor SendSlackNotification. Sans toucher au code existant.

Dans cet article on a essayé de présenter la manière dont on aime architecturer une application RubyOnRails en privilégiant les comportements. Ceci permet de facilement maintenir une app.

LittleBlueFox.io est la cyber-solution qui a un impact considérable sur la sécurité de toutes les activités: e-commerce, santé, immobilier, grands comptes, etc.

Sécurisez gratuitement