Hexagonal architecture in elixir

The fact is we did not indent from the start to use Commands and Queries, but as we used GraphQL, it kinda appeared naturally.
GraphQL mutations are natural commands. It added a lot of clarity for us to separate mutation/command from queries. Because for the query part we had to use the dataloader lib, which has a very specific way of working.

This is the protocol used to dispatch the commands :

defprotocol KairosCommand do
  @moduledoc """
  KairosCommand is the modules in which the usecase / commands that define your applications
  actions are.

  All Commands should implement this protocol to ease the call and the formatting of the parameters
  """

  alias KairosCommand.Context

  @type result :: term
  @type reason :: term

  @doc "modify the builder data with the opts (keyword list of the params)"
  @spec run(KairosCommand.t(), Context.t()) ::
          {:ok, result}
          | {:error, :forbidden}
          | {:error, :unauthorized}
          | {:error, {:validation_error, [KairosDomain.ValidationError.t()]}}
          | {:error, {:impossible_action, reason}}
          | {:error, {:argument_missing, missing_argument :: atom}}
          | {:error, {:resource_not_found, [name: atom, id: String.t()]}}
          | {:error, term}
  def run(command_data, command_context)
end


And here is the command definition (one of the most simple command we have) and the protocol implementation for the command handler.

defmodule KairosCommand.DeleteOneShift do
  alias KairosCommand.DeleteOneShift

  @type uuid() :: String.t()

  @type t() :: %DeleteOneShift{id: uuid() | nil}

  defstruct [
    :id
  ]
end

defimpl KairosCommand, for: KairosCommand.DeleteOneShift do
  alias KairosCommand.Policy
  alias KairosCommand.Context
  alias KairosCommand.DeleteOneShift
  alias KairosCommand.Ports.ShiftAggregateRepository

  @spec run(DeleteOneShift.t(), Context.t()) ::
          {:ok, :deleted}
          | {:error, :forbidden}
          | {:error, :unauthorized}
          | {:error, {:argument_missing, :id}}
          | {:error, {:resource_not_found, [name: atom, id: String.t()]}}
          | {:error, term}
  def run(%DeleteOneShift{id: shift_id}, ctx) do
    data = %{shift_id: shift_id}

    data
    |> Chain.new()
    |> Chain.next(&verify_is_authorized(&1, ctx))
    |> Chain.next(&load_shift_aggregate/1)
    |> Chain.next(&ShiftAggregateRepository.delete(&1.shift_aggregate))
    |> Chain.run()
  end
  
  ... (private functions are missing for clarity)
end

The mutation resolver creates the command and the context (with user permissions mainly) and calls KairosCommand.run(command, context)

Having one way to call commands and a standard response helped us mutualise and standardise the mutation resolver logic, and thus clarify the web/http part of the application. Quite a bit of plumbing in the end, but it happened over the course of 6 months, if we had to recreate that plumbing from scratch that would suck.
(Error handling, especially the form errors were automatically filled from the domain logic, translated and filled in the react form. Quite satisfying. Quite a bit of work for something Rails or Phoenix give for free… But Ecto.Changesets caused quite a bit of headaches so happy it ended up only in the infra, and business rules validations are free from it)