A repository of bitesize articles, tips & tricks
(in both English and French) curated by Mirego’s team.

Ecto.Multi comme interface principale de fonctionnalité

Ce texte décrit un pattern qu’on utilise dans quelques projets chez Mirego qui tire avantage de Ecto.Multi pour uniformiser l’interface de toutes nos fonctionnalités.

Tout d’abord, voici l’anatomie d’une fonctionnalité par défaut des generators de Phoenix:

defmodule MyApp.Controller.Moments do
  def create(conn, params) do
    case MyApp.Moments.create_moment(params) do
     {:ok, moment} -> json(conn, moment)
     {:error, error} -> put_status(json(conn, error), :unprocessable_entity)
    end
  end
end

On garde le même concept. Pour les "leafs" du projet (controller, resolver, tasks), l’intéraction avec des modules "Context" fonctionne bien. C’est une fois dans les modules "Context" qu’on structure un peu plus les fonctionnalités.

Tout commence avec le behavior UseCase qui défini le contrat à respecter par les modules qui implémente les fonctionnalités.

defmodule MyApp.UseCase do
  @callback init(Keyword.t()) :: Multi.t()
  @callback call(Multi.t()) :: Multi.t()
end

Ensuite on sépare chaque fonctionnalité dans son propre module:

  • MyApp.MyContext.UseCases.CreateMoment
  • MyApp.MyContext.UseCases.UpdateMoment
  • MyApp.MyContext.UseCases.AddComment
  • MyApp.MyContext.UseCases.Login
  • MyApp.MyContext.UseCases.SaveNotificationPreferences

Les modules "UseCase" ont tous la même interface (défini par MyApp.UseCase); une fonction init/1 et call/1. Très inspiré de la populaire librairie Plug.

Voici à quoi peut ressembler une fonctionnalité:

defmodule MyApp.MyContext.UseCases.CreateMoment do
  alias Ecto.Changeset
  alias Ecto.Multi

  alias MyApp.Moment

  @impl true
  def init(user: user, title: title, picture_url: picture_url) do
    Multi.new()
    |> Multi.put(:user, user)
    |> Multi.put(:params, %{title: title, picture_url: picture_url})
  end

  @impl true
  def call(multi) do
    Multi.run(multi, :moment, &insert_moment/2)
  end

  defp insert_moment(repo, %{params: params, user: user}) do
    %Moment{author_id: user.id}
    |> Changeset.cast(params, ~w(title picture_url)a)
    |> Changeset.put_change(:slug, generate_unique_slug())
    |> Changeset.unique_constraint(~w(picture_url)a)
    |> Changeset.unique_constraint(~w(slug)a)
    |> Changeset.foreign_key_constraint(:author_id)
    |> Changeset.unsafe_validate_unique(:picture_url, repo)
    |> Changeset.unsafe_validate_unique(:slug, repo)
    |> Changeset.validate_required(~w(title picture_url)a)
    |> repo.insert()
  end
end

Ce module encapsule toute la logique concernant la fonctionnalité. On peut voir que le schema Moment n’expose jamais de fonction générique changeset/2. On préfère mettre ces détails d’implémentation le plus proche possible de la fonctionnalité.

Résultat, un module qui dépend de Ecto et des schemas nécessaires, facilement testable unitairement. Ce module est plus facilement maintenable aussi parce qu’il encapsule toute la logique. Pas non plus besoin de naviguer le projet au complet, les fonctionnalités ont tous leur propre module.

defmodule MyApp.MyContext do
  alias MyApp.MyContext.UseCases
  alias MyApp.Repo

  def create_moment(user, title, picture_url) do
    [
      user: user,
      title: title,
      picture_url: picture_url,
    ]
    |> UseCases.CreateMoment.init()
    |> UseCases.CreateMoment.call()
    |> Repo.transaction()
  end
end

En ayant la même interface, il devient facile d’ajouter des "side-effects" à notre context en bénéficiant de l’interface d’Ecto.Multi et des transactions de DB.

  |> UseCases.CreateMoment.call()
  |> Notifications.enqueue(:new_moment_notification, &Notifications.NewMoment.payload/2)
  |> Repo.transaction()
  |> Audits.track(:create_moment, &UseCases.CreateMoment.to_audit_log/1, Repo)

Constance === maintenabilité === plaisir!

💜