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!
💜