From 674d230c9fd092fe21133551696598fe84f73f43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrien=20Pr=C3=A9?= Date: Fri, 15 May 2026 13:43:53 +0200 Subject: [PATCH 1/2] feat(router): support `causation_id: nil` to mark events as chain roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Callers can supply a `:causation_id` and have it persisted on resulting events — but there was still no way to express "this event has no cause." `Keyword.get(opts, :causation_id)` returns `nil` whether the option is absent or explicitly `nil`, so the dispatcher fell back to `command_uuid` in both cases. Resolve `:causation_id` at the router boundary using `Keyword.fetch/2` so absence and explicit-`nil` can be distinguished: - opt absent -> fall back to `command_uuid` (legacy) - `causation_id: ` -> use the uuid (since #103) - `causation_id: nil` -> use `nil` (new) Side benefit: the dispatcher's `to_execution_context/2` no longer carries the fallback `||` and reads `pipeline.causation_id` directly. Middleware that inspects `pipeline.causation_id` continues to see a uuid-or-nil, never a sentinel. Backwards compatible: - callers that never pass `:causation_id` get identical behaviour - callers that pass a uuid get the same behaviour as #103's fix - the only new behaviour is opt-in via `causation_id: nil` `WHERE causation_id IS NULL` now identifies chain roots without a self-join against the events table — significantly cheaper on large event stores. - lib/commanded/commands/router.ex: resolve `:causation_id` with `Keyword.fetch/2`; document the new option behaviour on `dispatch/2` - lib/commanded/commands/dispatcher.ex: drop the `|| command_uuid` fallback; stop destructuring `command_uuid` (now unused here) - test/commands/correlation_causation_test.exs: assert `causation_id: nil` produces `nil` on the resulting event --- lib/commanded/commands/dispatcher.ex | 4 ++-- lib/commanded/commands/router.ex | 21 ++++++++++++++++++-- test/commands/correlation_causation_test.exs | 10 ++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/lib/commanded/commands/dispatcher.ex b/lib/commanded/commands/dispatcher.ex index c8133312..37f5371a 100644 --- a/lib/commanded/commands/dispatcher.ex +++ b/lib/commanded/commands/dispatcher.ex @@ -158,7 +158,6 @@ defmodule Commanded.Commands.Dispatcher do defp to_execution_context(%Pipeline{} = pipeline, %Payload{} = payload) do %Pipeline{ command: command, - command_uuid: command_uuid, causation_id: causation_id, metadata: metadata } = pipeline @@ -175,7 +174,8 @@ defmodule Commanded.Commands.Dispatcher do %ExecutionContext{ command: command, - causation_id: causation_id || command_uuid, + # `causation_id` is resolved at the router boundary (#624) + causation_id: causation_id, correlation_id: correlation_id, metadata: metadata, handler: handler_module, diff --git a/lib/commanded/commands/router.ex b/lib/commanded/commands/router.ex index 76d5db63..6ff11224 100644 --- a/lib/commanded/commands/router.ex +++ b/lib/commanded/commands/router.ex @@ -449,7 +449,12 @@ defmodule Commanded.Commands.Router do Options: - `causation_id` - an optional UUID used to identify the cause of the - command being dispatched. + command being dispatched. When the option is omitted, the command's + own `command_uuid` is used (preserving prior behaviour). Pass + `causation_id: nil` explicitly to mark the resulting events as chain + roots — they will be persisted with `causation_id` set to `NULL`, + letting `WHERE causation_id IS NULL` queries identify roots without + a self-join. - `command_uuid` - an optional UUID used to identify the command being dispatched. When omitted, one will be generated automatically. @@ -552,10 +557,22 @@ defmodule Commanded.Commands.Router do opts = Keyword.merge(@command_opts, opts) application = Keyword.fetch!(opts, :application) - causation_id = Keyword.get(opts, :causation_id) command_uuid = Router.fetch_command_uuid(opts) consistency = Keyword.fetch!(opts, :consistency) correlation_id = Keyword.get_lazy(opts, :correlation_id, &UUID.uuid7/0) + + # Resolve `:causation_id` at the router boundary so the fallback + # decision happens once and downstream middleware/handlers see the + # final value. Three cases: + # - opt absent -> fall back to command_uuid (legacy) + # - `causation_id: ` -> use the uuid + # - `causation_id: nil` -> use nil (chain root, #624) + causation_id = + case Keyword.fetch(opts, :causation_id) do + {:ok, value} -> value + :error -> command_uuid + end + metadata = Keyword.fetch!(opts, :metadata) |> validate_metadata() retry_attempts = Keyword.get(opts, :retry_attempts) diff --git a/test/commands/correlation_causation_test.exs b/test/commands/correlation_causation_test.exs index 922c1bf2..a0473858 100644 --- a/test/commands/correlation_causation_test.exs +++ b/test/commands/correlation_causation_test.exs @@ -59,6 +59,16 @@ defmodule Commanded.Commands.CorrelationCasuationTest do assert event.causation_id == causation_id end + + test "should be `nil` on created event when `causation_id: nil` is passed explicitly" do + command = %OpenAccount{account_number: "ACC123", initial_balance: 500} + + :ok = BankRouter.dispatch(command, application: BankApp, causation_id: nil) + + [event] = EventStore.stream_forward(BankApp, "ACC123") |> Enum.to_list() + + assert is_nil(event.causation_id) + end end describe "`correlation_id`" do From 739199e970ad8798907a0bdb68efef99a68abda7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrien=20Pr=C3=A9?= Date: Thu, 21 May 2026 09:35:49 +0200 Subject: [PATCH 2/2] chore(router): shrink causation_id comment to satisfy credo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The added causation_id resolution pushed `__before_compile__`'s quote block past credo's `LongQuoteBlocks` threshold. Replace the multi-line explanation with a single-line comment — the case clauses are self-explanatory. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Adrien Pré --- lib/commanded/commands/router.ex | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/commanded/commands/router.ex b/lib/commanded/commands/router.ex index f352303a..79bc5467 100644 --- a/lib/commanded/commands/router.ex +++ b/lib/commanded/commands/router.ex @@ -577,12 +577,7 @@ defmodule Commanded.Commands.Router do consistency = Keyword.fetch!(opts, :consistency) correlation_id = Keyword.get_lazy(opts, :correlation_id, &UUID.uuid7/0) - # Resolve `:causation_id` at the router boundary so the fallback - # decision happens once and downstream middleware/handlers see the - # final value. Three cases: - # - opt absent -> fall back to command_uuid (legacy) - # - `causation_id: ` -> use the uuid - # - `causation_id: nil` -> use nil (chain root, #624) + # `Keyword.fetch/2` distinguishes absence from explicit `nil` (#624) causation_id = case Keyword.fetch(opts, :causation_id) do {:ok, value} -> value