Repository: commanded/commanded
Pull request #111 (2017-11-13 22:49:32)

Support composite command routers
You can use the `Commanded.Commands.CompositeRouter` macro to define a router comprised of other router modules.

This approach is useful if you prefer to construct a router per context and then combine them together to form a top level application router.

By using `Commanded.Commands.CompositeRouter` in your own module you can include other routers via the `router` macro:

```elixir
defmodule ApplicationRouter do
  use Commanded.Commands.CompositeRouter

  router BankAccountRouter
  router MoneyTransferRouter
end
```


Patch:
diff --git a/README.md b/README.md
index 79c62b1..8eb345e 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,7 @@ MIT License
       - [Dispatch consistency guarantee](guides/Commands.md#command-dispatch-consistency-guarantee)
       - [Dispatch returning execution result](guides/Commands.md#dispatch-returning-execution-result)
       - [Aggregate lifespan](guides/Commands.md#aggregate-lifespan)
+      - [Composite command routers](guides/Commands.md#composite-command-routers)
     - [Middleware](guides/Commands.md#middleware)
       - [Example middleware](guides/Commands.md#example-middleware)
   - [Events](guides/Events.md)
diff --git a/guides/Commands.md b/guides/Commands.md
index e8b2920..82746e2 100644
--- a/guides/Commands.md
+++ b/guides/Commands.md
@@ -345,3 +345,24 @@ end
 ```
 
 Commanded provides a `Commanded.Middleware.Logger` middleware for logging the name of each dispatched command and its execution duration.
+
+## Composite command routers
+
+You can use the `Commanded.Commands.CompositeRouter` macro to define a router comprised of other router modules. This approach is useful if you prefer to construct a router per context and then combine them together to form a top level application router.
+
+By using `Commanded.Commands.CompositeRouter` in your own module you can include other routers via the `router` macro:
+
+```elixir
+defmodule ApplicationRouter do
+  use Commanded.Commands.CompositeRouter
+
+  router BankAccountRouter
+  router MoneyTransferRouter
+end
+```
+
+Command dispatch works the same as any other router:
+
+```elixir
+:ok = ApplicationRouter.dispatch(%OpenAccount{account_number: "ACC123", initial_balance: 1_000})
+```
diff --git a/lib/commanded/commands/composite_router.ex b/lib/commanded/commands/composite_router.ex
new file mode 100644
index 0000000..315e33a
--- /dev/null
+++ b/lib/commanded/commands/composite_router.ex
@@ -0,0 +1,70 @@
+defmodule Commanded.Commands.CompositeRouter do
+  @moduledoc """
+  Composite router allows you to combine multiple router modules into a single
+  router able to dispatch any registered command using its corresponding router.
+
+  ## Example
+
+      defmodule ExampleCompositeRouter do
+        use Commanded.Commands.CompositeRouter
+
+        router BankAccountRouter
+        router MoneyTransferRouter
+      end
+
+      :ok = ExampleCompositeRouter.dispatch(%OpenAccount{account_number: "ACC123", initial_balance: 1_000})
+  """
+
+  defmacro __using__(_) do
+    quote do
+      require Logger
+
+      import unquote(__MODULE__)
+
+      @registered_commands %{}
+
+      @before_compile unquote(__MODULE__)
+    end
+  end
+
+  defmacro router(router_module) do
+    quote location: :keep do
+      for command <- unquote(router_module).registered_commands() do
+        case Map.get(@registered_commands, command) do
+          nil ->
+            @registered_commands Map.put(@registered_commands, command, unquote(router_module))
+
+          existing_router ->
+            raise "duplicate registration for #{inspect command} command, registered in both #{inspect existing_router} and #{inspect unquote(router_module)}"
+        end
+      end
+    end
+  end
+
+  defmacro __before_compile__(_env) do
+    quote do
+      @doc false
+      def dispatch(command), do: dispatch(command, [])
+
+      Enum.map(@registered_commands, fn {command_module, router} ->
+        Module.eval_quoted(__MODULE__, quote do
+          @doc false
+          def dispatch(%unquote(command_module){} = command, opts) do
+            unquote(router).dispatch(command, opts)
+          end
+        end)
+      end)
+
+      @doc """
+      Return an error if an unregistered command is dispatched.
+      """
+      def dispatch(command), do: unregistered_command(command)
+      def dispatch(command, opts), do: unregistered_command(command)
+
+      defp unregistered_command(command) do
+        Logger.error(fn -> "attempted to dispatch an unregistered command: #{inspect command}" end)
+        {:error, :unregistered_command}
+      end
+    end
+  end
+end
diff --git a/lib/commanded/commands/router.ex b/lib/commanded/commands/router.ex
index 5a4fc78..c0edfd8 100644
--- a/lib/commanded/commands/router.ex
+++ b/lib/commanded/commands/router.ex
@@ -112,19 +112,25 @@ defmodule Commanded.Commands.Router do
   defmacro __using__(_) do
     quote do
       require Logger
+
       import unquote(__MODULE__)
       @before_compile unquote(__MODULE__)
+
       @registered_commands []
-      @registered_middleware []
       @registered_identities %{}
-      @default_middleware [
-        Commanded.Middleware.ExtractAggregateIdentity,
-        Commanded.Middleware.ConsistencyGuarantee,
+      @registered_middleware []
+
+      @default [
+        middleware: [
+          Commanded.Middleware.ExtractAggregateIdentity,
+          Commanded.Middleware.ConsistencyGuarantee,
+        ],
+        consistency: :eventual,
+        dispatch_timeout: 5_000,
+        lifespan: Commanded.Aggregates.DefaultLifespan,
+        metadata: %{},
       ]
-      @default_consistency :eventual
-      @default_dispatch_timeout 5_000
-      @default_lifespan Commanded.Aggregates.DefaultLifespan
-      @default_metadata %{}
+
       @include_aggregate_version false
       @include_execution_result false
     end
@@ -165,11 +171,17 @@ defmodule Commanded.Commands.Router do
   """
   defmacro identify(aggregate_module, opts) do
     quote location: :keep do
-      if Map.has_key?(@registered_identities, unquote(aggregate_module)) do
-        raise "aggregate #{inspect unquote(aggregate_module)} has already been identified by"
+      unless Keyword.has_key?(unquote(opts), :by) do
+        raise "#{inspect unquote(aggregate_module)} aggregate identity is missing the `by` option"
       end
 
-      @registered_identities Map.put(@registered_identities, unquote(aggregate_module), unquote(opts))
+      case Map.get(@registered_identities, unquote(aggregate_module)) do
+        nil ->
+          @registered_identities Map.put(@registered_identities, unquote(aggregate_module), unquote(opts))
+
+        [by: existing_identity] ->
+          raise "#{inspect unquote(aggregate_module)} aggregate has already been identified by: `#{inspect existing_identity}`"
+      end
     end
   end
 
@@ -233,7 +245,9 @@ defmodule Commanded.Commands.Router do
 
       Returns `:ok` on success, or `{:error, reason}` on failure.
       """
-      @spec dispatch(command :: struct) :: :ok | {:error, reason :: term}
+      @spec dispatch(command :: struct) :: :ok
+        | {:error, :consistency_timeout}
+        | {:error, reason :: term}
       def dispatch(command)
       def dispatch(%unquote(command_module){} = command), do: do_dispatch(command, [])
 
@@ -300,12 +314,12 @@ defmodule Commanded.Commands.Router do
       defp do_dispatch(%unquote(command_module){} = command, opts) do
         causation_id = Keyword.get(opts, :causation_id)
         correlation_id = Keyword.get(opts, :correlation_id) || UUID.uuid4()
-        consistency = Keyword.get(opts, :consistency) || unquote(consistency) || @default_consistency
-        metadata = Keyword.get(opts, :metadata) || @default_metadata
-        timeout = Keyword.get(opts, :timeout) || unquote(timeout) || @default_dispatch_timeout
+        consistency = Keyword.get(opts, :consistency) || unquote(consistency) || @default[:consistency]
+        metadata = Keyword.get(opts, :metadata) || @default[:metadata]
+        timeout = Keyword.get(opts, :timeout) || unquote(timeout) || @default[:dispatch_timeout]
         include_aggregate_version = Keyword.get(opts, :include_aggregate_version) || @include_aggregate_version
         include_execution_result = Keyword.get(opts, :include_execution_result) || @include_execution_result
-        lifespan = Keyword.get(opts, :lifespan) || unquote(lifespan) || @default_lifespan
+        lifespan = Keyword.get(opts, :lifespan) || unquote(lifespan) || @default[:lifespan]
 
         default_identity = unquote(identity)
         default_identity_prefix = unquote(identity_prefix)
@@ -338,7 +352,7 @@ defmodule Commanded.Commands.Router do
           timeout: timeout,
           lifespan: lifespan,
           metadata: metadata,
-          middleware: @registered_middleware ++ @default_middleware,
+          middleware: @registered_middleware ++ @default[:middleware],
         })
       end
     end
@@ -346,6 +360,9 @@ defmodule Commanded.Commands.Router do
 
   defmacro __before_compile__(_env) do
     quote do
+      @doc false
+      def registered_commands, do: @registered_commands
+
       @doc """
       Return an error if an unregistered command is dispatched
       """


Test patch:
diff --git a/test/commands/composite_router_test.exs b/test/commands/composite_router_test.exs
new file mode 100644
index 0000000..dbf40a5
--- /dev/null
+++ b/test/commands/composite_router_test.exs
@@ -0,0 +1,81 @@
+defmodule Commanded.Commands.CompositeRouterTest do
+  use Commanded.StorageCase
+
+  alias Commanded.ExampleDomain.{BankAccount,MoneyTransfer}
+  alias Commanded.ExampleDomain.{OpenAccountHandler,DepositMoneyHandler,TransferMoneyHandler,WithdrawMoneyHandler}
+  alias Commanded.ExampleDomain.BankAccount.Commands.{OpenAccount,DepositMoney,WithdrawMoney}
+  alias Commanded.ExampleDomain.MoneyTransfer.Commands.TransferMoney
+  alias Commanded.Commands.ExecutionResult
+
+  defmodule UnregisteredCommand do
+    @moduledoc false
+    defstruct [:uuid]
+  end
+
+  defmodule BankAccountRouter do
+    @moduledoc false
+    use Commanded.Commands.Router
+
+    identify BankAccount, by: :account_number
+
+    dispatch OpenAccount, to: OpenAccountHandler, aggregate: BankAccount
+    dispatch DepositMoney, to: DepositMoneyHandler, aggregate: BankAccount
+    dispatch WithdrawMoney, to: WithdrawMoneyHandler, aggregate: BankAccount
+  end
+
+  defmodule MoneyTransferRouter do
+    @moduledoc false
+    use Commanded.Commands.Router
+
+    identify MoneyTransfer, by: :transfer_uuid
+    dispatch TransferMoney, to: TransferMoneyHandler, aggregate: MoneyTransfer
+  end
+
+  defmodule ExampleCompositeRouter do
+    @moduledoc false
+    use Commanded.Commands.CompositeRouter
+
+    router BankAccountRouter
+    router MoneyTransferRouter
+  end
+
+  describe "composite router" do
+    test "should dispatch command to registered handler" do
+      assert :ok = ExampleCompositeRouter.dispatch(%OpenAccount{account_number: "ACC123", initial_balance: 1_000})
+      assert :ok = ExampleCompositeRouter.dispatch(%TransferMoney{transfer_uuid: UUID.uuid4(), debit_account: "ACC123", credit_account: "ACC456", amount: 500})
+    end
+
+    test "should dispatch command to registered handler with options" do
+      command = %OpenAccount{account_number: "ACC123", initial_balance: 1_000}
+      assert {:ok, %ExecutionResult{}} = ExampleCompositeRouter.dispatch(command, include_execution_result: true)
+    end
+
+    test "should fail to dispatch unregistered command" do
+      assert {:error, :unregistered_command} = ExampleCompositeRouter.dispatch(%UnregisteredCommand{})
+    end
+
+    test "should fail to compile when duplicate commands registered" do
+      assert_raise RuntimeError, "duplicate registration for Commanded.ExampleDomain.MoneyTransfer.Commands.TransferMoney command, registered in both Commanded.Commands.CompositeRouterTest.MoneyTransferRouter and DuplicateMoneyTransferRouter", fn ->
+        Code.eval_string """
+          alias Commanded.ExampleDomain.{MoneyTransfer,TransferMoneyHandler}
+          alias Commanded.ExampleDomain.MoneyTransfer.Commands.TransferMoney
+          alias Commanded.Commands.CompositeRouterTest.MoneyTransferRouter
+
+          defmodule DuplicateMoneyTransferRouter do
+            use Commanded.Commands.Router
+
+            identify MoneyTransfer, by: :transfer_uuid
+            dispatch TransferMoney, to: TransferMoneyHandler, aggregate: MoneyTransfer
+          end
+
+          defmodule ExampleCompositeRouter do
+            use Commanded.Commands.CompositeRouter
+
+            router MoneyTransferRouter
+            router DuplicateMoneyTransferRouter
+          end
+        """
+      end
+    end
+  end
+end
diff --git a/test/commands/routing_commands_test.exs b/test/commands/routing_commands_test.exs
index ab001e0..a827bae 100644
--- a/test/commands/routing_commands_test.exs
+++ b/test/commands/routing_commands_test.exs
@@ -76,8 +76,36 @@ defmodule Commanded.Commands.RoutingCommandsTest do
     end
   end
 
+  test "should ensure identity field is present" do
+    assert_raise RuntimeError, "Commanded.ExampleDomain.BankAccount aggregate identity is missing the `by` option", fn ->
+      Code.eval_string """
+        alias Commanded.ExampleDomain.BankAccount
+
+        defmodule DuplicateRouter do
+          use Commanded.Commands.Router
+
+          identify BankAccount, prefix: "account-"
+        end
+      """
+    end
+  end
+
+  test "should prevent duplicate identity for an aggregate" do
+    assert_raise RuntimeError, "Commanded.ExampleDomain.BankAccount aggregate has already been identified by: `:account_number`", fn ->
+      Code.eval_string """
+        alias Commanded.ExampleDomain.BankAccount
+
+        defmodule DuplicateRouter do
+          use Commanded.Commands.Router
+
+          identify BankAccount, by: :account_number
+          identify BankAccount, by: :duplicate
+        end
+      """
+    end
+  end
+
   test "should prevent duplicate registrations for a command" do
-    # compile time safety net to prevent duplicate command registrations
     assert_raise RuntimeError, "duplicate command registration for: Commanded.ExampleDomain.BankAccount.Commands.OpenAccount", fn ->
       Code.eval_string """
         alias Commanded.ExampleDomain.BankAccount
@@ -98,7 +126,6 @@ defmodule Commanded.Commands.RoutingCommandsTest do
   end
 
   test "should prevent registration for a command handler without a `handle/2` function" do
-    # compile time safety net to prevent duplicate command registrations
     assert_raise RuntimeError, "command handler InvalidHandler does not define a function: handle/2", fn ->
       Code.eval_string """
         alias Commanded.ExampleDomain.BankAccount