# Exaop
A minimal elixir library for aspect-oriented programming.
## Installation
Add `exaop` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:exaop, "~> 0.1"}
]
end
```
## Usage
Unlike common AOP patterns, Exaop does not introduce any additional behavior to
existing functions, as it may bring complexity and make the control flow
obscured. Elixir developers prefer explicit over implicit, thus invoking the
cross-cutting behavior by simply calling the plain old function generated by
pointcut definitions is better than using some magic like module attributes and
macros to decorate and weave a function.
### Hello World
Use Exaop in a module, then define some pointcuts to separate the cross-cutting
logic:
```elixir
defmodule Foo do
use Exaop
check :validity
set :compute
end
```
When you compile the file, the following warnings would occur:
```
warning: function check_validity/3 required by behaviour Foo.ExaopBehaviour is not implemented (in module Foo)
foo.exs:1: Foo (module)
warning: function set_compute/3 required by behaviour Foo.ExaopBehaviour is not implemented (in module Foo)
foo.exs:1: Foo (module)
```
It reminds you to implement the corresponding callbacks required by your pointcut definitions:
```elixir
defmodule Foo do
use Exaop
check :validity
set :compute
@impl true
def check_validity(%{b: b} = _params, _args, _acc) do
if b == 0 do
{:error, :divide_by_zero}
else
:ok
end
end
@impl true
def set_compute(%{a: a, b: b} = _params, _args, acc) do
Map.put(acc, :result, a / b)
end
end
```
A function `__inject__/2` is generated in the above module `Foo`. When it is
called, the callbacks are triggered in the order defined by your pointcut
definitions.
Throughout the execution of the pointcut callbacks, an accumulator is passed
and updated after running each callback. The execution process may be halted
by a return value of a callback.
If the execution is not halted by any callback, the final accumulator value is
returned by the `__inject__/2` function. Otherwise, the return value of the
callback that terminates the entire execution process is returned.
In the above example, the value of the accumulator is returned if the
`check_validity` is passed:
```elixir
iex> params = %{a: 1, b: 2}
iex> initial_acc = %{}
iex> Foo.__inject__(params, initial_acc)
%{result: 0.5}
```
The halted error is returned if the execution is aborted:
```elixir
iex> params = %{a: 1, b: 0}
iex> initial_acc = %{}
iex> Foo.__inject__(params, initial_acc)
{:error, :divide_by_zero}
```
### Pointcut definitions
```elixir
check :validity
set :compute
```
We've already seen the pointcut definitions in the example before.
`check_validity/3` and `set_compute/3` are the pointcut callback functions
required by these definitions.
Additional arguments can be set:
```elixir
check :validity, some_option: true
set :compute, {:a, :b}
```
### Pointcut callbacks
#### Naming and arguments
All types of pointcut callbacks have the same function signature. Each callback
function following the naming convention in the example, using an underscore
to connect the pointcut type and the following atom as the callback function
name.
Each callback has three arguments and each argument can be of any Elixir term.
The first argument of the callback function is passed from the first argument
of the caller `__inject__/2`. The argument remains unchanged in each callback
during the execution process.
The second argument of the callback function is passed from its pointcut
definition, for example, `set :compute, :my_arg` passes `:my_arg` as the
second argument of its callback function `set_compute/3`.
The third argument is the accumulator. It is initialized as the second
argument of the caller `__inject__/2`. The value of accumulator is updated or
remains the same after each callback execution, depending on the types and
the return values of the callback functions.
#### Types and behaviours
Each kind of pointcut has different impacts on the execution process and the
accumulator.
- `check`
- does not change the value of the accumulator.
- the execution of the generated function is halted if its callback
return value matches the pattern `{:error, _}`.
- the execution continues if its callback returns `:ok`.
- `set`
- does not halt the execution process.
- sets the accumulator to its callback return value.
- `preprocess`
- allows to change the value of the accumulator or halt the execution process.
- the execution of the generated function is halted if its callback return
value matches the pattern `{:error, _}`.
- the accumulator is updated to the wrapped `acc` if its callback return
value matches the pattern `{:ok, acc}`.
View documentation of these macros for details.
### A more in-depth example
Exaop is ready for production and makes complex application workflows simple
and self-documenting. In practice, we combine it with some custom simple macros
as a method to separate cross-cutting concerns and decouple business logic.
Note that we do not recommend overusing it, it is only needed when the workflow
gets complicated, and the pointcuts should be strictly restricted to the domain
of cross-cutting logic, not the business logic body itself.
Here's a more complex example, a wallet balance transfer. The configuration
loading, context setting and transfer validations are separated, but the main
transfer logic remains untouched. The example also introduces an external
callback, which is defined in a module other than its pointcut definition.
```elixir
defmodule Wallet do
@moduledoc false
use Exaop
alias Wallet.AML
require Logger
## Definitions for cross-cutting concerns
set :config, [:max_allowed_amount, :fee_rate]
set :accounts
check :amount, guard: :positive
check :amount, guard: {:lt_or_eq, :max_allowed_amount}
check :recipient, :not_equal_to_sender
check AML
set :fee
check :balance
@doc """
A function injected by explicitly calling __inject__/2 generated by Exaop.
"""
def transfer(%{from: _, to: _, amount: _} = info) do
info
|> __inject__(%{})
|> handle_inject(info)
end
defp handle_inject({:error, _} = error, info) do
Logger.error("transfer failed", error: error, info: info)
end
defp handle_inject(_acc, info) do
# Put the actual transfer logic here:
# Wallet.transfer!(acc, info)
Logger.info("transfer validated and completed", info: info)
end
## Setters required by the above concern definitions.
@impl true
def set_accounts(%{from: from, to: to}, _args, acc) do
balances = %{"Alice" => 100, "Bob" => 30}
acc
|> Map.put(:sender_balance, balances[from])
|> Map.put(:recipient_balance, balances[to])
end
@impl true
def set_config(_params, keys, acc) do
keys
|> Enum.map(&{&1, Application.get_env(:my_app, &1, default_config(&1))})
|> Enum.into(acc)
end
defp default_config(key) do
Map.get(%{fee_rate: 0.01, max_allowed_amount: 1_000}, key)
end
@impl true
def set_fee(%{amount: amount}, _args, %{fee_rate: fee_rate} = acc) do
Map.put(acc, :fee, amount * fee_rate)
end
## Checkers required by the above concern definitions.
@impl true
def check_amount(%{amount: amount}, args, acc) do
args
|> Keyword.fetch!(:guard)
|> do_check_amount(amount, acc)
end
defp do_check_amount(:positive, amount, _acc) do
if amount > 0 do
:ok
else
{:error, :amount_not_positive}
end
end
defp do_check_amount({:lt_or_eq, key}, amount, acc)
when is_atom(key) do
max = Map.fetch!(acc, key)
if max && amount <= max do
:ok
else
{:error, :amount_exceeded}
end
end
@impl true
def check_recipient(%{from: from, to: to}, :not_equal_to_sender, _acc) do
if from == to do
{:error, :invalid_recipient}
else
:ok
end
end
@impl true
def check_balance(%{amount: amount}, _args, %{fee: fee, sender_balance: balance}) do
if balance >= amount + fee do
:ok
else
{:error, :insufficient_balance}
end
end
end
defmodule Wallet.AML do
@moduledoc """
A module defining external Exaop callbacks.
"""
@behaviour Exaop.Checker
@aml_blacklist ~w(Trump)
@impl true
def check(%{from: from, to: to}, _args, _acc) do
cond do
from in @aml_blacklist ->
{:error, {:aml_check_failed, from}}
to in @aml_blacklist ->
{:error, {:aml_check_failed, to}}
true ->
:ok
end
end
end
```
## License
[The MIT License](https://github.com/nobrick/exaop/blob/master/LICENSE)