Sea v0.1.0 Sea View Source
Side-effect abstraction - put your synchronous side-effects in order.
Sea consists of following modules:
Sea.Signal
- defines signal that will get emitted to defined observersSea.Observer
- Defines observer capable of handling signals emitted to it
Usage
Basic example
In Sea, you define signal and a bunch of observers that get called upon signal emission:
defmodule SomeSignal do
use Sea.Signal
emit_to SomeObserver
defstruct [:some_data]
end
defmodule SomeObserver do
use Sea.Observer
@impl true
def handle_signal(%SomeSignal{some_data: some_data}) do
IO.puts("Acting upon some signal with data: #{inspect(some_data)}")
end
end
SomeSignal.emit(%SomeSignal{some_data: "foo"})
Signal-Observer naming convention
In order to simplify working with growing number of signals and their observers scattered across project modules, you may define observers like this:
defmodule MyApp.X.SomeSignal do
use Sea.Signal
emit_within MyApp.{Y, Z}
defstruct [:some_data]
end
defmodule MyApp.Y.SomeObserver do
use Sea.Observer
@impl true
def handle_signal(%MyApp.X.SomeSignal{some_data: some_data}) do
IO.puts("Y acting upon some signal with data: #{inspect(some_data)}")
end
end
defmodule MyApp.Z.SomeObserver do
use Sea.Observer
@impl true
def handle_signal(%MyApp.X.SomeSignal{some_data: some_data}) do
IO.puts("Z acting upon some signal with data: #{inspect(some_data)}")
end
end
Sea.Signal.emit(%MyApp.X.SomeSignal{some_data: "foo"})
Decoupling contexts
Let’s assume you have a service that causes several side-effects across the system:
defmodule MyApp.Sales.CreateInvoiceService do
alias MyApp.Repo
alias MyApp.Sales.Invoice
alias MyApp.{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. This code is a great case to introduce the benefits of Sea.
Let’s start by introducing a signal capable of building itself from our invoice struct:
defmodule MyApp.Sales.InvoiceCreatedSignal do
use Sea.Signal
emit_within MyApp.{Analytics, Customers, Inventory}
defstruct [:customer_id, :product_id]
def build(%MyApp.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 call it from the service instead of calling all these external modules:
defmodule MyApp.Sales.CreateInvoiceService do
alias MyApp.Repo
alias MyApp.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 MyApp.Analytics.InvoiceCreatedObserver do
use Sea.Observer
def handle_signal(signal) do
# ...
end
end
defmodule MyApp.Customers.InvoiceCreatedObserver do
use Sea.Observer
def handle_signal(signal) do
# ...
end
end
defmodule MyApp.Inventory.InvoiceCreatedObserver do
use Sea.Observer
def handle_signal(signal) do
# ...
end
end
That’s it - the side-effect has been properly facilitated.
Testing
With Sea acting as your hub for distributing side-effects across modules you may have two main testing scenarios involving signals:
Signals disabled for unit testing purposes. In such scenario you want your signal emission stubbed away from the logic of unit that normally does emit it. In some of those cases, you would still like to verify that the signal does get emitted without causing side-effects.
Signals enabled for testing integration between contexts. In such scenario you want your signal to behave like it does in final product - to trigger observers all over the place and cause all the side-effects so you can check if they behave properly in integration.
Ideally, you’d like these two kinds of tests to execute asynchronously. And perhaps you’d like to do some other custom mocking or stubbing on top of the signals.
Picking up on the previous example, you could want to ensure that CreateInvoiceService
can be
tested in isolation from side-effects in Analytics
, Customers
and Inventory
contexts, but at
the same time to also create an integration test which does opt-in for the side-effects.
Signal mocking with Mox
Sea covers all of these cases by leveraging the excellent Mox
library to define mocks on top of
signals. It also provides Sea.SignalMocking
module with helpers useful to minimize the
boilerplate around testing and mocking signals.
In order to mock signals, go through the following procedure:
- Add
Mox
to the project. - Add config that by default points to
SomeSignal
, but toSomeSignal.Mock
in test env. - Call the signal module fetched from config instead of
SomeSignal
in your app code. - Define mock by calling
Sea.SignalMocking.defsignalmock/1
in test helper or support script. - Call
Sea.SignalMocking.enable_signal/1
orSea.SignalMocking.disable_signal/1
in test cases.
By leveraging Mox, Sea gives you all the options for testing and verifying mocks that Mox does. In order to do so, assume the following module naming convention:
SomeSignal
is your actual signal implementationSomeSignal.Mock
is the mocked version of itSomeSignal.Behaviour
is the behaviour implemented by both of the above
This means that you may do the following in your test case in order to ensure that SomeSignal
does get called with specific input without it causing side-effects:
disable_signal(SomeSignal)
expect(SomeSignal.Mock, :emit, fn %SomeInput{} -> :ok end)
# ...
# call & test the code which emits SomeSignal
# ...
verify!(SomeSignal.Mock)