Getting Started With Maestro

Introduction

Dusting off Maestro for a brand new project has reminded me that posts like this one and the ones to follow are absolutely necessary. Differentiating Maestro from the other alternatives out there is only part of the goal for today. The other part is to explain a bit about why I decided to build it at all. I’ll finish the post off by adding it to an existing Phoenix project.

Why did you make Maestro?

An excellent question that I get asked by business people and developers alike. It seems anyone with a long afternoon and a headfull of CQRS, Event Sourcing, and/or Domain-Driven Design knowledge can make an event sourcing library; if they’ve got a long weekend, they can even get started on a CQRS framework. I agree! In Elixir alone, there are more than a handful as seen here. The most note-worthy and comprehensive implementation is Commanded, and if we had been going all-in on DDD (and maybe we should have), Commanded would’ve been the right choice.

Maestro is decidedly not a CQRS framework; it is an opionated library for implementing an event sourced application. The idea began at Strange Loop ‘16 after viewing Bobby Calderwood’s talk on Commander. I learned everything I could from the resources above and many more. I watched talks and discussed design ideas with coworkers, but the project that was driving the early designs was shelved before we had more than a prototype.

Fast-forward to my Pylon years. We’d successfully migrated from Python to Elixir, and I had taken a project that required an audit log of changes, a way to recover previously published versions, and a large (and growing) number of mutations for a complicated and deep model. Event sourcing was a great fit, and we knew exactly where our boundaries were.

What sets it apart?

All of my efforts were focused on consistency and the relationship between command processing and event application. Our problem didn’t require a lot of the extra machinery that comes with a more comprehensive CQRS framework; things like process managers, asynchronous event busses, eventually consistent projections, etc. were all things that would’ve just detracted. With the exception of indices for finding/retrieving an aggregate, our query side views are updated rarely and only after an extensive publication/validation process that made even an event bus unnecessary. Practically speaking, the aggregate’s data model (or at least subsets of it) were precisely the model we wanted to expose to the client.

Since we were using Elixir, the GenServer as a command processor/aggregate root just made sense. The goal was to make the library do one thing and do it well. Maestro handles the logical operation of taking a command, finding your command handler code, evaluating it against an up-to-date aggregate, and taking any generated events and committing them to the log.

Even the limited way we handle projections is toward this goal, we only accommodate projections that are executed within the same transaction that commits the events.

Sequence Numbers

One of the simpler things that sets it apart from a CQRS framework is the sequence number. For any given aggregate, the sequence number is monotonic, and no conflicts are allowed. This means that every instance of the aggregate root will process events in the exact same order. With respect to an individual aggregate, the set of events is totally ordered.

This is accomplished by a uniqueness constraint at the database. To make it easier to use, the library manages updating sequence numbers per-event as part of the pre-commit step. You don’t have to think about it. You just create events that are populated with the type, id, and data that are your responsibility anyway.

What does this mean for you? If you’re using Maestro to build an aggregate, two partitioned nodes (who can still see the datastore) are able to make independent progress that doesn’t violate consistency. You get to write your command and event handlers like they were running on one machine.

Hybrid Logical Clocks and Causality

A more particular differentiating factor is the use of the Hybrid Logical Clock (more specifically the variant provided by HLClock) and its form of timestamps. We use them in a couple of places for a couple of reasons. If you are unfamiliar with the HLC, an excellent writeup is available with examples is available here.

The first and simplest use case is as a replacement for traditional timestamps in the event log. Since HLClock’s timestamps include node identification information, you can use this information for debugging or even sorting events to create a single, consistently ordered view of all the events across aggregates.

The second use case is as the aggregate ID. Unlike a traditional UUID, this intentionally embeds causal information about which node was responsible for creating the aggregate.

In both cases, alternatives exist and could be used. DateTimes are sufficient with respect to the single aggregate event log since the sequence number controls the ordering. UUIDs are also sufficient for creating IDs that are unique. HLC timestamps do just a little bit more to help in reasoning about how/when failures happen, so it was worth the investment.

How do I use it?

To thoroughly demonstrate this library and some advanced use cases, we’re going to build a graph editor; it’s a project that’s always been interesting to me, and there are some personal and work projects that are adjacent enough to a generic graph editor that I should be able to stay motivated for whole hours - possibly longer.

  • Add maestro to your dependencies: {:maestro, "~> 0.3"} and mix deps.get
  • Create the migration for events and snapshots: mix maestro.create_event_store_migration
  • Configure Maestro minimally: config :maestro, storage_adapter: Maestro.Store.Postgres, repo: Bramble.Repo

Here you can see Graph which implements the minimal callbacks for handling state/snapshots of a well-behaved Maestro.Aggregate.Root. Everything else is done via commands and events. To start with, there are only the CreateGraph, AddNode, AddEdge commands and their respective GraphCreated, NodeAdded and EdgeAdded events. Finally, the projection used to instantiate the index of name to aggregate IDs is shown as well at GraphOperations.

The aggregate simply implements the minimal callbacks to get things wired:

defmodule Bramble.Graph do
  @moduledoc """
  Currently, a named collection of nodes and edges with minimal validation on
  what any of these things are since we're not sure what the requirements are
  yet.
  """

  use Maestro.Aggregate.Root,
    command_prefix: Bramble.Graph.Commands,
    event_prefix: Bramble.Graph.Events

  alias Bramble.Repo

  alias Maestro.Types.Snapshot

  @type model :: %{
    name: String.t(),
    nodes: [node :: String.t()],
    edges: %{(source_id :: String.t()) => (dest_id :: String.t())},
    inserted_at: DateTime.t() | nil
  }

  def initial_state do
    %{name: "", nodes: [], edges: %{}, inserted_at: nil}
  end

  def prepare_snapshot(state), do: state

  def use_snapshot(_curr, %Snapshot{body: state}) do
    Map.new(state, fn {k, v} ->
      {String.to_existing_atom(k), v}
    end)
  end
end

As an example of minimal effort for commands (useful when you’re just trying to get things moving), the CreateGraph command does not validate since the projection (which is executed transactionally with the commit of the GraphCreated event) will be used to ensure that no duplicate names exist.

defmodule Bramble.Graph.Commands.CreateGraph do
  @moduledoc """
  Graphs are unique by name for simplicity
  """

  @behaviour Maestor.Aggregate.CommandHandler

  alias Maestro.Types.Event

  def eval(agg, %{data: %{name: graph_name}}) do
    [
      %Event{
        aggregate_id: agg.id,
        type: "graph_created",
        body: %{
          "name" => graph_name,
          "inserted_at" => DateTime.utc_now()
        }
      }
    ]
  end
end

The events are simply updating the aggregate at this point, since we aren’t maintaining any extra information for the aggregate yet. Of note here, is that we always should prepare for the representation that we output in our commands and the representation we’d expect to see if we were starting from a cold state (i.e. events coming from the database).

defmodule Bramble.Graph.Events.GraphCreated do
  @moduledoc """
  Set name and inserted_at
  """

  @behaviour Maestro.Aggregate.EventHandler

  def apply(state, event) do
    state
    |> Map.put(:name, Map.fetch!(event.body, "name"))
    |> Map.put(:inserted_at, inserted_at(event.body))
  end

  # parse the datetime from the event if necessary
  defp inserted_at(%{"inserted_at" => %DateTime{} = dt}), do: dt

  defp inserted_at(%{"inserted_at" => dt_str}) do
    {:ok, dt, _offset} = DateTime.from_iso8601(dt_str)

    dt
  end
end

Projections are simple to build/extend as well, but it requires updating the Graph.

defmodule Bramble.Graph.Projections.GraphOperations do
  @moduledoc """
  Transactional projections handling properties relating to the graph
  as a whole (i.e. name, rename, delete, etc.)
  """

  @behaviour Maestro.Aggregate.ProjectionHandler

  alias Bramble.{Graph.Schemas, Repo}

  def project(%{
        aggregate_id: agg_id,
        type: "graph_created",
        body: %{"name" => gname}
      }) do
    Repo.insert(%Schemas.Graph{name: gname, aggregate_id: agg_id})
  end

  # since _all_ projections see _all_ events, we must explicitly skip
  # the ones we're not interested in
  def project(_), do: nil
end
defmodule Bramble.Graph do
  use Maestro.Aggregate.Root,
    command_prefix: Bramble.Graph.Commands,
    event_prefix: Bramble.Graph.Events,
    projections: [Bramble.Graph.Projections.GraphOperations]

    ...
end

Wrapping Up

Using Maestro is easy. A lot of extra mix tasks could be created to help streamline this process (and I would welcome the help should the desire be made known), but the somewhat manual nature of setting this all up is intentional as these steps are infrequent enough that I haven’t found the need for any such tooling. I also have a personal policy that automation should be deferred (for simple tasks such as these) until you have a clear picture on what you want.

In the near future, we’ll explore some more advanced concepts around Maestro such as debugging a bad event from the REPL, stricter command/event modeling with structs, repurposing events and versioning, and more complicated model validation.