Decoupling contexts

This guide is basically a walkthrough for the library part of the invoicing_app. You may want to take a look at a complete application code in order to see the whole picture.

You start with a service that causes several side-effects across the system:

defmodule InvoicingApp.Sales.CreateInvoiceService do
  alias InvoicingApp.Repo
  alias InvoicingApp.Sales.Invoice
  alias InvoicingApp.{Analytics, Customers, Inventory}

  def call(product_id, customer_id) do
    invoice_attrs = [
      product_id: product_id,
      customer_id: customer_id
    ]

    Repo.transaction(fn ->
      invoice =
        invoice_attrs
        |> Invoice.changeset()
        |> Repo.insert()

      Analytics.increase_invoice_count()
      Customers.mark_customer_active(customer_id)
      Inventory.decrease_stock(product_id)
    end)
  end
end

As you can see, each external side-effect is directly invoked from the original service, which means that:

  • this code doesn’t emphasize (doesn’t signal) where’s the point at which invoice is created
  • it’s hard to tell the logical boundary between this unit’s logic and external side-effects
  • side-effects are hard to mock away for unit testing purposes
  • each side-effect must have relevant external (possibly redundant) API interface available
  • it’s tempting to pass rich and coupled data structures to those external APIs
  • each additional side-effect will cause this unit’s code to grow further

We’ll fix these issues by abstracting away these side-effects.

Let’s start by introducing a signal capable of building itself from our invoice struct:

defmodule InvoicingApp.Sales.InvoiceCreatedSignal do
  use Sea.Signal

  emit_to InvoicingApp.{Analytics, Customers, Inventory}

  defstruct [:customer_id, :product_id]

  def build(%InvoicingApp.Sales.Invoice{customer_id: customer_id, product_id: product_id}) do
    %__MODULE__{
      customer_id: customer_id,
      product_id: product_id
    }
  end
end

Now let’s emit it from the service instead of calling all these external modules:

defmodule InvoicingApp.Sales.CreateInvoiceService do
  alias InvoicingApp.Repo
  alias InvoicingApp.Sales.{Invoice, InvoiceCreatedSignal}

  def call(product_id, customer_id) do
    invoice_attrs = [
      product_id: product_id,
      customer_id: customer_id
    ]

    Repo.transaction(fn ->
      invoice =
        invoice_attrs
        |> Invoice.changeset()
        |> Repo.insert()

      InvoiceCreatedSignal.emit(invoice)
    end)
  end
end

And finally, let’s ensure that observers are in place to handle the external side-effects:

defmodule InvoicingApp.Analytics.InvoiceCreatedObserver do
  use Sea.Observer

  def handle_signal(signal) do
    # ...
  end
end

defmodule InvoicingApp.Customers.InvoiceCreatedObserver do
  use Sea.Observer

  def handle_signal(signal) do
    # ...
  end
end

defmodule InvoicingApp.Inventory.InvoiceCreatedObserver do
  use Sea.Observer

  def handle_signal(signal) do
    # ...
  end
end

That’s it - the side-effect has been properly facilitated and all the perks presented in motivation section of the readme are applied in full force.