Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions guides/explanations/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
45 changes: 45 additions & 0 deletions guides/explanations/fork-differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
47 changes: 47 additions & 0 deletions lib/commanded/aggregate/identity.ex
Original file line number Diff line number Diff line change
@@ -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
5 changes: 3 additions & 2 deletions lib/commanded/middleware/extract_aggregate_identity.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ defmodule Commanded.Middleware.ExtractAggregateIdentity do

@behaviour Commanded.Middleware

alias Commanded.Aggregate.Identity
alias Commanded.Middleware.Pipeline
import Pipeline

Expand Down Expand Up @@ -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}}
Expand Down
40 changes: 37 additions & 3 deletions test/commands/custom_identity_routing_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"},
Expand Down Expand Up @@ -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
Loading