Tracking changes with context using Phoenix and Ecto

In the previous post, we discussed why it’s important to add context to the application’s data changes. Now we’re going to see how we can do that in Elixir with Phoenix and Ecto.

Adding context to Context Link to heading

What does that even mean?

Context is an overloaded term, especially in programming. Let’s see the two definitions we will be using:

Context:

1) the situation within which something exists or happens, and that can help explain it; 2) the influences and events related to a particular event or situation;

(Cambridge Dictionary)

(Phoenix) Context:

Contexts are dedicated modules that expose and group related functionality. For example, any time you call Elixir’s standard library, be it Logger.info/1 or Stream.map/2, you are accessing different contexts. Internally, Elixir’s logger is made of multiple modules, but we never interact with those modules directly. We call the Logger module the context, exactly because it exposes and groups all of the logging functionality.

(Phoenix docs)

They mean different things but work exceptionally well together, organizing actions leading to changes and explaining why they happened. So from now on, I’ll refer to the latter just as events.

Cancelling an order Link to heading

Following our previous example of an online shop, we have two Phoenix Contexts: Accounts and Sales. In Accounts, we have a User schema, and in Sales, Client and Order schemas.

To cancel an order we could have a Sales.cancel_order/1 function as simple as:

def cancel_order(%Order{} = order) do
  order
  |> Order.changeset({status: "cancelled"})
  |> Repo.update()
end

Order canceled. But why? Link to heading

To know why an order was canceled, let’s refactor the function and add the reason and the user who did it.

def cancel_order(%Order{} = order, reason, %User{} = actor) do
  Multi.new()
  |> Multi.update(:order, Order.changeset(order, %{status: "cancelled"}))
  |> Multi.run(:audit, &cancel_order_audit(&1, &2, reason, actor))
  |> Repo.transaction()
end

defp cancel_order_audit(_repo, %{order: order}, reason, actor) do
  metadata = %{
    source: "manual",
    reason: reason
  }

  "order_cancelled"
  |> OrderAudit.event(order.id, actor.id, metadata)
  |> OrderAudit.save()
end

We’re also using Ecto.Multi to make the operation atomic (the event log will be saved only when the order is canceled without errors) and added a cancel_order_audit/4 callback function that setups the metadata and saves the changes.

The OrderAudit module is a standard Ecto Schema, with a twist:

defmodule AlchemyReaction.Sales.OrderAudit do
  # highlight-range{1-6}
  use AlchemyReaction.Audit,
    repo: AlchemyReaction.Repo,
    schema: __MODULE__,
    events: [
      "order_cancelled"
    ]

  use Ecto.Schema
  import Ecto.Changeset

  alias AlchemyReaction.Accounts.User
  alias AlchemyReaction.Sales.Order

  schema "orders_audit" do
    field :event, :string
    field :metadata, :map
    belongs_to :row, Order
    belongs_to :actor, User

    timestamps(updated_at: false)
  end

  @doc false
  def changeset(%__MODULE__{} = order_audit, attrs) do
    order_audit
    |> cast(attrs, [:event, :row_id, :metadata, :actor_id])
    |> validate_required([:event, :row_id, :actor_id])
  end
end

We are using the Audit module and setting some options, including the events list.

There’s only one event, order_cancelled, but we could have many more: item_removed, payment_type_changed, shipping_address_changed, and so on.

I usually have multiple audit modules inside a context, one for each subcontext.

Subcontext?

WE NEED TO GO DEEPER

For those familiar with DDD, they are like aggregate roots.

The Order schema is composed of other smaller schemas that make no sense existing outside it, like order items and payments. So the Order aggregates this additional data, and as the most important one, it is the root, hence Aggregate Root. But I like to call them subcontexts because the terminology is closer to Phoenix Contexts. :)

Can you come up with another good candidate for a subcontext in Sales context?

IMPORTANT: a sub context is an internal concept and should only be interacted with from its parent context. In this example, all calls from outside will still happen in Sales context.

Caveat: an Ecto schema should be a simple mapping between a data source and an Elixir struct, but I couldn’t find a better place for this example. In an actual application I organize my contexts a little differently, so this doesn’t happen. I’ll write about it in the future.

The Audit module Link to heading

So what is it? How does it work?

Teach me the ways of the force

Here’s the complete code:

defmodule AlchemyReaction.Audit do
  require Logger

  defmacro __using__(opts) do
    repo = Keyword.fetch!(opts, :repo)
    schema = Keyword.fetch!(opts, :schema)
    events = Keyword.fetch!(opts, :events)

    if Enum.empty?(events), do: raise(ArgumentError, message: "event list empty")

    save_function =
      quote do
        # Output:
        #
        # def save(%Ecto.Changeset{} = changes) do
        #   AlchemyReaction.Audit.save(changes, repo)
        # end
        def save(%Ecto.Changeset{} = changes) do
          unquote(__MODULE__).save(changes, unquote(repo))
        end
      end

    event_signature =
      quote do
        def event(event, row_id, actor_id, metadata \\ nil)
      end

    event_log_functions =
      for event_name <- events do
        quote do
          # Output:
          #
          # def event("foo_event", row_id, actor_id, metadata) do
          #   AlchemyReaction.Audit.event(schema, "foo_event", row_id, actor_id, metadata)
          # end
          def event(unquote(event_name), row_id, actor_id, metadata) do
            unquote(__MODULE__).event(
              unquote(schema),
              unquote(event_name),
              row_id,
              actor_id,
              metadata
            )
          end
        end
      end

    [save_function, event_signature, event_log_functions]
  end

  @doc """
  Saves the event to the `Repo`.

  In case of nothing changes, do nothing.

  It returns `{:ok, :no_changes}` if nothing changed, `{:ok, struct}` if the log
  has been successfully saved or `{:error, changeset}` in case of error.
  """
  @spec save(Ecto.Changeset.t() | :no_changes, Ecto.Repo.t()) ::
          {:ok, :no_changes}
          | {:ok, Ecto.Schema.t()}
          | {:error, Ecto.Changeset.t()}

  def save(:no_changes, _repo) do
    {:ok, :no_changes}
  end

  def save(%Ecto.Changeset{} = changes, repo) do
    Logger.debug("Saving audit info...")
    repo.insert(changes)
  end

  @doc """
  Creates a `schema` changeset for the `event` identified by `row_id` and caused
  by `actor_id`.

  The given `metadata` can be either `nil`, `Ecto.Changeset`, struct or map.

  It returns `:no_changes` in case of an `Ecto.Changeset` metadata that changed nothing
  or an `Ecto.Changeset` with the event ready to be inserted.
  """
  @spec event(Ecto.Schema.t(), String.t(), non_neg_integer(), non_neg_integer(), Ecto.Changeset.t() | struct() | map() | nil) ::
          :no_changes | Ecto.Changeset.t()

  def event(schema, event, row_id, actor_id, metadata \\ nil)

  def event(_, _, _, _, %Ecto.Changeset{changes: changes} = _changeset)
      when map_size(changes) == 0 do
    :no_changes
  end

  def event(schema, event, row_id, actor_id, %Ecto.Changeset{} = changeset) do
    metadata = %{
      before: Map.take(changeset.data, Map.keys(changeset.changes)),
      after: changeset.changes
    }

    audit_changeset(schema, event, row_id, actor_id, metadata)
  end

  def event(schema, event, row_id, actor_id, %_{} = struct) do
    metadata =
      struct
      |> Map.from_struct()
      |> Map.drop([:__meta__])

    audit_changeset(schema, event, row_id, actor_id, metadata)
  end

  def event(schema, event, row_id, actor_id, metadata) when is_map(metadata) do
    audit_changeset(schema, event, row_id, actor_id, metadata)
  end

  defp audit_changeset(schema, event, row_id, actor_id, metadata) do
    schema
    |> struct()
    |> schema.changeset(%{
      event: event,
      row_id: row_id,
      actor_id: actor_id,
      metadata: metadata
    })
  end
end

When using this module, a pair of event/3 and event/4 functions is defined for each event, helping you avoid typing out a wrong event name thanks to pattern matching. The difference between them is the metadata parameter. Most events will need additional metadata, but some don’t. The event name alone is sufficient to describe what happened.

A save/1 function is also defined… To save the event log… For real! Except when there’s nothing to save because nothing changed. But it saves most of the time…

The event/3 and event/4 functions delegate the calls to the event/5 inside the Audit module, which creates the log changeset. Thanks to pattern matching again, the metadata can be built from different sources:

  • an Ecto.Changeset: the changes and their original values are extracted in a %{before: %{}, after: %{}} map; or it returns a :no_changes atom in case nothing changed. The latter bypasses saving, so it doesn’t create a useless log entry
  • a struct: all fields from the struct are added to the metadata
  • a map: anything you need to contextualize the event. Take care to put only what’s necessary, adding too little or too much hinders your ability to see what happened clearly

At first, it may seem too much work to create the callback functions and build the events manually, but it pays off. The event log gives you multiple benefits: you can show the changes in a timeline, store changes over time for accountability reasons, have changes traceability for security, and much more.

I’m using this in one of my applications and it’s been a blast.