Repository: commanded/commanded
Pull request #402 (2020-08-24 21:32:35)

Pass execution context to aggregates before execution
Add an optional `before_execute/2` callback to Aggregates, called just before the `execute/2` callback, with the user-defined aggregate state and the current execution context. This is particularly useful to do some setup in the context of the aggregate process (e.g. set the aggregate's own logger's metadata).

See issue https://github.com/commanded/commanded/issues/403


Patch:
diff --git a/lib/commanded/aggregates/aggregate.ex b/lib/commanded/aggregates/aggregate.ex
index 0120097..7e7d830 100644
--- a/lib/commanded/aggregates/aggregate.ex
+++ b/lib/commanded/aggregates/aggregate.ex
@@ -437,33 +437,45 @@ defmodule Commanded.Aggregates.Aggregate do
     end
   end
 
+  defp before_execute_command(_aggregate_state, %ExecutionContext{before_execute: nil}), do: :ok
+
+  defp before_execute_command(aggregate_state, %ExecutionContext{} = context) do
+    %ExecutionContext{handler: handler, before_execute: before_execute} = context
+    Kernel.apply(handler, before_execute, [aggregate_state, context])
+  end
+
   defp execute_command(%ExecutionContext{} = context, %Aggregate{} = state) do
     %ExecutionContext{command: command, handler: handler, function: function} = context
     %Aggregate{aggregate_state: aggregate_state} = state
 
     Logger.debug(fn -> describe(state) <> " executing command: " <> inspect(command) end)
 
-    case Kernel.apply(handler, function, [aggregate_state, command]) do
-      {:error, _error} = reply ->
-        {reply, state}
+    with :ok <- before_execute_command(aggregate_state, context) do
+      case Kernel.apply(handler, function, [aggregate_state, command]) do
+        {:error, _error} = reply ->
+          {reply, state}
 
-      none when none in [:ok, nil, []] ->
-        {{:ok, []}, state}
+        none when none in [:ok, nil, []] ->
+          {{:ok, []}, state}
 
-      %Commanded.Aggregate.Multi{} = multi ->
-        case Commanded.Aggregate.Multi.run(multi) do
-          {:error, _error} = reply ->
-            {reply, state}
+        %Commanded.Aggregate.Multi{} = multi ->
+          case Commanded.Aggregate.Multi.run(multi) do
+            {:error, _error} = reply ->
+              {reply, state}
 
-          {aggregate_state, pending_events} ->
-            persist_events(pending_events, aggregate_state, context, state)
-        end
+            {aggregate_state, pending_events} ->
+              persist_events(pending_events, aggregate_state, context, state)
+          end
 
-      {:ok, pending_events} ->
-        apply_and_persist_events(pending_events, context, state)
+        {:ok, pending_events} ->
+          apply_and_persist_events(pending_events, context, state)
 
-      pending_events ->
-        apply_and_persist_events(pending_events, context, state)
+        pending_events ->
+          apply_and_persist_events(pending_events, context, state)
+      end
+    else
+      {:error, _error} = reply ->
+        {reply, state}
     end
   rescue
     error ->
diff --git a/lib/commanded/aggregates/execution_context.ex b/lib/commanded/aggregates/execution_context.ex
index 67797f1..ebed8cd 100644
--- a/lib/commanded/aggregates/execution_context.ex
+++ b/lib/commanded/aggregates/execution_context.ex
@@ -20,11 +20,16 @@ defmodule Commanded.Aggregates.ExecutionContext do
     - `handler` - the module that handles the command. It may be either the
       aggregate module itself or a separate command handler module.
 
-    - `function` - the name of function, as an atom, that handles the command.
+    - `function` - the name of the function, as an atom, that handles the command.
       The default value is `:execute`, used to support command dispatch directly
       to the aggregate module. For command handlers the `:handle` function is
       used.
 
+    - `before_execute` - the name of the function, as an atom, that prepares the
+      command before execution, called just before `function`. The default value
+      is `nil`, disabling it. It should return `:ok` on success or `{:error, any()}`
+      to cancel the dispatch.
+
     - `lifespan` - a module implementing the `Commanded.Aggregates.AggregateLifespan`
       behaviour to control the aggregate instance process lifespan. The default
       value, `Commanded.Aggregates.DefaultLifespan`, keeps the process running
@@ -41,8 +46,9 @@ defmodule Commanded.Aggregates.ExecutionContext do
     :command,
     :causation_id,
     :correlation_id,
-    :function,
     :handler,
+    :function,
+    before_execute: nil,
     retry_attempts: 0,
     returning: false,
     lifespan: DefaultLifespan,
diff --git a/lib/commanded/commands/dispatcher.ex b/lib/commanded/commands/dispatcher.ex
index 25ef902..0b67c2a 100644
--- a/lib/commanded/commands/dispatcher.ex
+++ b/lib/commanded/commands/dispatcher.ex
@@ -19,6 +19,7 @@ defmodule Commanded.Commands.Dispatcher do
       :consistency,
       :handler_module,
       :handler_function,
+      :handler_before_execute,
       :aggregate_module,
       :identity,
       :identity_prefix,
@@ -141,6 +142,7 @@ defmodule Commanded.Commands.Dispatcher do
       correlation_id: correlation_id,
       handler_module: handler_module,
       handler_function: handler_function,
+      handler_before_execute: handler_before_execute,
       lifespan: lifespan,
       retry_attempts: retry_attempts,
       returning: returning
@@ -153,6 +155,7 @@ defmodule Commanded.Commands.Dispatcher do
       metadata: metadata,
       handler: handler_module,
       function: handler_function,
+      before_execute: handler_before_execute,
       lifespan: lifespan,
       retry_attempts: retry_attempts,
       returning: returning
diff --git a/lib/commanded/commands/router.ex b/lib/commanded/commands/router.ex
index e1fd340..6fca1e6 100644
--- a/lib/commanded/commands/router.ex
+++ b/lib/commanded/commands/router.ex
@@ -497,6 +497,7 @@ defmodule Commanded.Commands.Router do
         @aggregate Keyword.fetch!(command_opts, :aggregate)
         @handler Keyword.fetch!(command_opts, :to)
         @function Keyword.fetch!(command_opts, :function)
+        @before_execute Keyword.get(command_opts, :before_execute)
         @lifespan Keyword.get(command_opts, :lifespan)
         @identity Keyword.get(command_opts, :identity)
         @identity_prefix Keyword.get(command_opts, :identity_prefix)
@@ -559,6 +560,7 @@ defmodule Commanded.Commands.Router do
             consistency: consistency,
             handler_module: @handler,
             handler_function: @function,
+            handler_before_execute: @before_execute,
             aggregate_module: @aggregate,
             identity: identity,
             identity_prefix: identity_prefix,
@@ -615,6 +617,7 @@ defmodule Commanded.Commands.Router do
   @register_params [
     :to,
     :function,
+    :before_execute,
     :aggregate,
     :identity,
     :identity_prefix,


Test patch:
diff --git a/test/commands/routing_commands_test.exs b/test/commands/routing_commands_test.exs
index 3827a13..9bb0320 100644
--- a/test/commands/routing_commands_test.exs
+++ b/test/commands/routing_commands_test.exs
@@ -220,7 +220,7 @@ defmodule Commanded.Commands.RoutingCommandsTest do
     assert_raise RuntimeError,
                  """
                  unexpected dispatch parameter "id"
-                 available params are: to, function, aggregate, identity, identity_prefix, timeout, lifespan, consistency
+                 available params are: to, function, before_execute, aggregate, identity, identity_prefix, timeout, lifespan, consistency
                  """,
                  fn ->
                    Code.eval_string("""