Organizing observers

You can organize your signals and observers in any way you like and link them freely with Sea.Signal.emit_to/1 macro calls. Sea doesn’t limit you in this regard as it’ll depend on specific use cases how best to name and split observers mapped to the signals. In this guide, we’ll review some of possible approaches and propose technical solutions for applying them efficiently.

For instance, you could choose to share observer logic for handling multiple signals by implementing single observer module with multiple Sea.Observer.handle_signal/1 clauses, target it from multiple signals and pattern-match specific signal or group of signals there. The AnalyticsObserver from the getting started guide could become such multi-signal observer, ie:

defmodule AnalyticsObserver do
  use Sea.Observer

  @impl true
  def handle_signal(%InvoiceCreatedSignal{invoice_number: invoice_number}) do
    IO.puts("Adding invoice #{invoice_number} to analytics accumulators")
    increase_count(:invoice)
  end

  @impl true
  def handle_signal(%UserRegisteredSignal{email: email}) do
    IO.puts("Adding user #{email} to analytics accumulators")
    increase_count(:user)
  end

  defp increase_count(type) do
    # common logic here
  end
end

This approach was applied in exemplary InvoicingApp.Analytics.Observer module.

An opposite approach would be to implement observers dedicated to specific signals and perhaps map signal and observer names across context modules in order to simplify working with a growing number of signals and observers scattered across project modules, ie:

defmodule InvocingApp.Sales.InvoiceCreatedSignal do
  use Sea.Signal

  emit_to InvocingApp.Customers.InvoiceCreatedObserver
  emit_to InvocingApp.Inventory.InvoiceCreatedObserver

  # ...
end

defmodule InvocingApp.Customers.InvoiceCreatedObserver do
  use Sea.Observer

  # ...
end

defmodule InvocingApp.Inventory.InvoiceCreatedObserver do
  use Sea.Observer

  # ...
end

This is indeed how exemplary InvoicingApp.Customers.InvoiceCreatedObserver and InvoicingApp.Inventory.InvoiceCreatedObserver were organized.

Regardless of the approach, you may want to avoid a fishy practice from the above example - forcing signals nested in InvoicingApp.Sales module to pick specific observer modules internal to InvoicingApp.Customers or InvoicingApp.Inventory - they should be a private implementation detail of these modules. This can be achieved by delegating handle_signal from entry module to one of its nested observers. For the “single observer” approach, this could be as simple as:

def InvoicingApp.Analytics do
  use Sea.Observer

  defdelegate handle_signal(signal), to: __MODULE__.Observer
end

For “one signal one observer” approach we could pick observer dynamically based on signal name:

def InvoicingApp.Customers do
  use Sea.Observer

  @impl true
  def handle_signal(signal = %{__struct__: signal_mod}) do
    signal_name = signal_mod |> Module.split() |> List.last()
    observer_name = String.replace(signal_name, ~r/Signal$/, "Observer")
    observer_mod = :"#{__MODULE__}.#{observer_name}"

    observer_mod.handle_signal(signal)
  end
end

Here, the original signal name was taken and the Signal suffix was replaced with Observer suffix in order to infer the name of child observer module within specific entry module.

Finally, you could put one (or both) of these observer organization strategies into reusable module that you would subsequently use in all entry modules that include observers. A basic version of such router that covers routing strategies described above is available in Sea.SignalRouter module. You can easily create your own as well.

This way all of your observers will be organized in consistent and predictable way, with a single module that describes the rules governing this aspect of your application and with explicit yet compact references to it all over the project.