|
243 | 243 | - Visualize event handler execution in your tracing backend |
244 | 244 | - Correlate event processing with command dispatch using span links |
245 | 245 | - Configurable span relationships (`:link`, `:child`, `:none`) |
| 246 | + |
| 247 | +### **Custom Initial State for Aggregates** |
| 248 | +[PR #49](https://github.com/straw-hat-team/commanded/pull/49) |
| 249 | + |
| 250 | +**Changes:** |
| 251 | +- Added `initial_state` option to command router's `dispatch` macro |
| 252 | +- Allows specifying a module that implements `initial_state/0` callback |
| 253 | +- Useful for protobuf-generated modules or custom default values |
| 254 | + |
| 255 | +**Usage:** |
| 256 | + |
| 257 | +```elixir |
| 258 | +# State module with initial_state/0 callback |
| 259 | +defmodule BankAccountState do |
| 260 | + defstruct [:account_number, :balance, status: :uninitialized] |
| 261 | + |
| 262 | + def initial_state, do: %__MODULE__{} |
| 263 | +end |
| 264 | + |
| 265 | +# Aggregate module (behavior only, no struct) |
| 266 | +defmodule BankAccount do |
| 267 | + def execute(%BankAccountState{status: :uninitialized}, %OpenAccount{} = cmd) do |
| 268 | + %AccountOpened{account_number: cmd.account_number} |
| 269 | + end |
| 270 | + |
| 271 | + def apply(%BankAccountState{} = state, %AccountOpened{} = event) do |
| 272 | + %BankAccountState{state | account_number: event.account_number, status: :open} |
| 273 | + end |
| 274 | +end |
| 275 | + |
| 276 | +defmodule MyRouter do |
| 277 | + use Commanded.Commands.Router |
| 278 | + |
| 279 | + # Calls BankAccountState.initial_state/0 to create initial state |
| 280 | + dispatch [OpenAccount, DepositMoney], |
| 281 | + to: BankAccount, |
| 282 | + initial_state: BankAccountState, |
| 283 | + identity: :account_number |
| 284 | +end |
| 285 | +``` |
| 286 | + |
| 287 | +**Benefits:** |
| 288 | +- Decouples aggregate behavior from state representation |
| 289 | +- State module controls its own initialization (like `to:` pattern) |
| 290 | +- Enables use of protobuf-generated structs with custom default values |
| 291 | +- Handles protobuf zero-value defaults naturally (strings default to `""`, not `nil`) |
| 292 | +- Backwards compatible - if `initial_state` is omitted, `struct(AggregateModule)` is used |
| 293 | + |
| 294 | +**Rationale:** |
| 295 | + |
| 296 | +In the upstream Commanded, the aggregate module serves dual purposes: it defines both the state struct and the behavior (`execute/2` and `apply/2` functions). This coupling becomes problematic when you want to use protobuf-generated modules for state. |
| 297 | + |
| 298 | +Protobuf modules are code-generated and shouldn't be manually modified—any changes would be overwritten on regeneration. To add aggregate behavior to a protobuf struct, you'd need to write custom protobuf extensions or use workarounds, adding complexity to your build pipeline. |
| 299 | + |
| 300 | +By separating the aggregate (behavior) from the state (data structure), you can: |
| 301 | + |
| 302 | +```elixir |
| 303 | +# Generated by protobuf - don't modify |
| 304 | +defmodule MyApp.Proto.BankAccountState do |
| 305 | + use Protobuf, syntax: :proto3 |
| 306 | + # ... generated fields ... |
| 307 | +end |
| 308 | + |
| 309 | +# Your code - aggregate behavior with initial_state returning the protobuf struct |
| 310 | +defmodule MyApp.BankAccount do |
| 311 | + alias MyApp.Proto.BankAccountState |
| 312 | + |
| 313 | + def initial_state, do: %BankAccountState{status: :STATUS_UNINITIALIZED} |
| 314 | + |
| 315 | + def execute(%BankAccountState{} = state, %OpenAccount{} = cmd), do: ... |
| 316 | + def apply(%BankAccountState{} = state, %AccountOpened{} = event), do: ... |
| 317 | +end |
| 318 | +``` |
| 319 | + |
| 320 | +**Why a callback instead of `struct/1`?** |
| 321 | + |
| 322 | +Elixir's `struct(Module)` initializes all fields to `nil`, but protobuf has different default semantics—strings default to `""`, integers to `0`, booleans to `false`, etc. Using `%BankAccountState{}` (the struct literal syntax) invokes protobuf's generated `new/0` which applies the correct proto3 default values. The `initial_state/0` callback gives you control over this initialization rather than relying on `struct/1`. |
| 323 | + |
| 324 | +This separation follows the principle that data representation and business logic are distinct concerns that benefit from being in separate modules. It also aligns more closely with the [Functional Decider Pattern](https://thinkbeforecoding.com/post/2021/12/17/functional-event-sourcing-decider), where the aggregate is a set of pure functions (`execute`, `apply`, `initial_state`) operating on state, rather than a stateful object that owns its data structure. |
0 commit comments