From 0d4567523e3a1682fcae344f34e8e8f0f139e6fb Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Tue, 13 Jan 2026 02:46:56 -0500 Subject: [PATCH] feat: add aggregate identity handling to use `Commanded.Aggregate.Identity` protocol Signed-off-by: Yordis Prieto --- guides/explanations/commands.md | 12 +++-- guides/explanations/fork-differences.md | 45 ++++++++++++++++++ lib/commanded/aggregate/identity.ex | 47 +++++++++++++++++++ .../middleware/extract_aggregate_identity.ex | 5 +- .../commands/custom_identity_routing_test.exs | 40 ++++++++++++++-- 5 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 lib/commanded/aggregate/identity.ex diff --git a/guides/explanations/commands.md b/guides/explanations/commands.md index 681ddc34..cf76fbf0 100644 --- a/guides/explanations/commands.md +++ b/guides/explanations/commands.md @@ -159,16 +159,16 @@ The prefix is used as the stream identity when appending, and reading, the aggre #### Custom aggregate identity -Any module that implements the `String.Chars` protocol can be used for an aggregate's identity. By default this includes the following Elixir built-in types: strings, integers, floats, atoms, and lists. +Any module that implements the `Commanded.Aggregate.Identity` protocol can be used for an aggregate's identity. By default this falls back to the `String.Chars` protocol, which includes the following Elixir built-in types: strings, integers, floats, atoms, and lists. -You can define your own custom identity modules and implement the `String.Chars` protocol for them: +You can define your own custom identity modules and implement the `Commanded.Aggregate.Identity` protocol for them: ```elixir defmodule AccountNumber do defstruct [:branch, :account_number] - defimpl String.Chars do - def to_string(%AccountNumber{branch: branch, account_number: account_number}), + defimpl Commanded.Aggregate.Identity do + def to_stream_id(%AccountNumber{branch: branch, account_number: account_number}), do: branch <> ":" <> account_number end end @@ -185,6 +185,10 @@ open_account = %OpenAccount{ :ok = BankApp.dispatch(open_account) ``` +> #### Note {: .info} +> +> For backwards compatibility, if `Commanded.Aggregate.Identity` is not implemented for a type, it will fall back to using the `String.Chars` protocol. + ### Timeouts A command handler has a default timeout of 5 seconds. The same default as a `GenServer.call/3` process call. It must handle the command in this period, otherwise the call fails and the caller process exits. diff --git a/guides/explanations/fork-differences.md b/guides/explanations/fork-differences.md index 6404d6f1..293c7184 100644 --- a/guides/explanations/fork-differences.md +++ b/guides/explanations/fork-differences.md @@ -171,3 +171,48 @@ end - Uses standard W3C `traceparent` and `tracestate` headers stored in event metadata - Event handlers can extract trace context to create span links or parent-child relationships - Non-invasive - only adds metadata when a span is active + +### **Aggregate Identity Protocol** +[PR #43](https://github.com/straw-hat-team/commanded/pull/43) + +**Changes:** +- Added `Commanded.Aggregate.Identity` protocol for converting aggregate identities to stream ID strings +- Protocol uses `@fallback_to_any true` with default implementation delegating to `String.Chars` +- Updated `ExtractAggregateIdentity` middleware to use the new protocol + +**Usage:** +```elixir +defmodule AccountNumber do + defstruct [:branch, :account_number] + + defimpl Commanded.Aggregate.Identity do + def to_stream_id(%AccountNumber{branch: branch, account_number: account_number}), + do: branch <> ":" <> account_number + end +end +``` + +**Benefits:** +- Provides a dedicated protocol for aggregate identity conversion with clear semantics +- Backwards compatible - falls back to `String.Chars` for existing implementations +- Avoids conflict with `String.Chars` which is a general-purpose protocol used for many purposes (logging, display, string interpolation, etc.) where the desired format may differ from the stream ID format + +**Rationale:** + +The `String.Chars` protocol is commonly implemented for various purposes unrelated to aggregate identity. For example, you might use it to format a value for API responses: + +```elixir +# String.Chars for API response formatting +defimpl String.Chars, for: AccountNumber do + def to_string(%AccountNumber{branch: branch, account_number: account_number}), + do: "#{branch}/#{account_number}" +end + +# But need a different format for stream IDs in storage +defimpl Commanded.Aggregate.Identity, for: AccountNumber do + def to_stream_id(%AccountNumber{branch: branch, account_number: account_number}), + do: "#{branch}:#{account_number}" +end +``` + +With a dedicated protocol, the API response format and the event store stream ID format are properly separated and can evolve independently. diff --git a/lib/commanded/aggregate/identity.ex b/lib/commanded/aggregate/identity.ex new file mode 100644 index 00000000..3a66a94a --- /dev/null +++ b/lib/commanded/aggregate/identity.ex @@ -0,0 +1,47 @@ +defprotocol Commanded.Aggregate.Identity do + @moduledoc """ + Protocol to convert an aggregate identity to a stream ID string. + + Any module that implements this protocol can be used for an aggregate's + identity. By default, this falls back to using the `String.Chars` protocol, + which includes the following Elixir built-in types: strings, integers, floats, + atoms, and lists. + + ## Example + + defmodule AccountNumber do + defstruct [:branch, :account_number] + + defimpl Commanded.Aggregate.Identity do + def to_stream_id(%AccountNumber{branch: branch, account_number: account_number}) do + branch <> ":" <> account_number + end + end + end + + The custom identity will be converted to a string during command dispatch. + This is used as the aggregate's identity and determines the stream to append + its events in the event store. + """ + + @fallback_to_any true + + @doc """ + Convert the aggregate identity to a stream ID string. + """ + @spec to_stream_id(t) :: String.t() + def to_stream_id(identity) +end + +defimpl Commanded.Aggregate.Identity, for: Any do + @moduledoc """ + Default implementation falling back to `String.Chars` protocol. + + This ensures backwards compatibility with existing code that implements + `String.Chars` for custom identity types. + """ + + def to_stream_id(identity) do + to_string(identity) + end +end diff --git a/lib/commanded/middleware/extract_aggregate_identity.ex b/lib/commanded/middleware/extract_aggregate_identity.ex index b7efc880..db15405e 100644 --- a/lib/commanded/middleware/extract_aggregate_identity.ex +++ b/lib/commanded/middleware/extract_aggregate_identity.ex @@ -6,6 +6,7 @@ defmodule Commanded.Middleware.ExtractAggregateIdentity do @behaviour Commanded.Middleware + alias Commanded.Aggregate.Identity alias Commanded.Middleware.Pipeline import Pipeline @@ -45,10 +46,10 @@ defmodule Commanded.Middleware.ExtractAggregateIdentity do defp extract_aggregate_uuid(%Pipeline{}), do: {:error, :invalid_aggregate_identity} - # Attempt to convert the aggregate identity to a string via the `String.Chars` protocol. + # Convert the aggregate identity to a string via the `Commanded.Aggregate.Identity` protocol. defp identity_to_string(aggregate_uuid) do try do - to_string(aggregate_uuid) + Identity.to_stream_id(aggregate_uuid) rescue Protocol.UndefinedError -> {:error, {:unsupported_aggregate_identity_type, aggregate_uuid}} diff --git a/test/commands/custom_identity_routing_test.exs b/test/commands/custom_identity_routing_test.exs index a25e98f4..7eef7257 100644 --- a/test/commands/custom_identity_routing_test.exs +++ b/test/commands/custom_identity_routing_test.exs @@ -10,8 +10,8 @@ defmodule Commanded.Commands.CustomIdentityRoutingTest do @derive Jason.Encoder defstruct [:branch, :account_number] - defimpl String.Chars do - def to_string(%AccountNumber{branch: branch, account_number: account_number}), + defimpl Commanded.Aggregate.Identity do + def to_stream_id(%AccountNumber{branch: branch, account_number: account_number}), do: branch <> ":" <> account_number end end @@ -31,7 +31,7 @@ defmodule Commanded.Commands.CustomIdentityRoutingTest do :ok end - describe "identify aggregate using `String.Chars` protocol" do + describe "identify aggregate using `Commanded.Aggregate.Identity` protocol" do test "should dispatch command to aggregate instance" do open_account = %OpenAccount{ account_number: %AccountNumber{branch: "B1", account_number: "ACC123"}, @@ -61,4 +61,38 @@ defmodule Commanded.Commands.CustomIdentityRoutingTest do CustomIdentityRouter.dispatch(open_account, application: DefaultApp) end end + + describe "backwards compatibility with `String.Chars` protocol" do + defmodule LegacyAccountNumber do + @derive Jason.Encoder + defstruct [:branch, :account_number] + + # Only implements String.Chars, not Commanded.Aggregate.Identity + defimpl String.Chars do + def to_string(%LegacyAccountNumber{branch: branch, account_number: account_number}), + do: branch <> "-" <> account_number + end + end + + defmodule LegacyIdentityRouter do + use Commanded.Commands.Router + + dispatch OpenAccount, + to: OpenAccountHandler, + aggregate: BankAccount, + identity: :account_number + end + + test "should dispatch command using String.Chars fallback" do + open_account = %OpenAccount{ + account_number: %LegacyAccountNumber{branch: "B2", account_number: "ACC456"}, + initial_balance: 500 + } + + assert :ok = LegacyIdentityRouter.dispatch(open_account, application: DefaultApp) + + events = EventStore.stream_forward(DefaultApp, "B2-ACC456") |> Enum.to_list() + assert length(events) == 1 + end + end end