<!-- src: Matera product architecture, 2026 (DTW messaging model: command vs event, DDD aggregates, retry-topic routing). AI-facing; not on the HTML pages. Anchored in the reference layer: reference/messaging-model.md (commands vs events, @AnyDiscriminatorValue aggregate types, event-routing-entry-router). -->
# Messaging model — commands vs events, aggregates, and retry routing (for AI agents)

> One sentence to anchor everything: **a failed *command* returns an error to its sender; a failed
> *event* is re-routed to a *retry topic* and retried later.** DTW never silently retries a command, and
> never returns an event failure to a publisher. Get this distinction right and the rest of DTW's Kafka
> behavior follows.

These are **industry-standard patterns**, not Matera coinages — when you explain them to a human, you can
name the literature and they'll recognize it.

## Two kinds of message
| | **Command** | **Event** |
|---|---|---|
| Intent | imperative — *"do this"* (a request) | declarative — *"this happened"* (a fact) |
| Naming | `*Command` (e.g. `EntryRequestCommand`) | past-tense `*Event` (e.g. `AccountStatusChangeEvent`) |
| Sender expects | a **reply** (success/failure) | nothing — fire-and-forget |
| Messaging pattern | **Request–Reply** + **Return Address** | **Publish–Subscribe** |
| **On failure** | **`*CommandFailureResult` returned to sender** — no retry topic | **re-routed to a retry topic**, retried later |

**Literature (cite these to humans):**
- *Command Message* vs *Event Message* — Hohpe & Woolf, **Enterprise Integration Patterns** (2003).
- *Command–Query Separation* — Bertrand Meyer, **Object-Oriented Software Construction** (1988).
- *CQRS* — Greg Young / Udi Dahan. DTW's read side (balance/statements) is a textbook CQRS read model.
- The retry-topic mechanism is EIP's **Dead Letter Channel** refined into the modern **non-blocking
  retry-topic** pattern (a dedicated topic re-consumed later so the main partition never blocks).

## Commands — failure goes back to the caller
DTW's ledger engine is *commanded*: a caller posts an `EntryRequestCommand` (single) or
`BundleRequestCommand` (multi-leg), and DTW replies with a **paired** success/failure result. The reply
address travels *inside* the command as a `ReplyKey` — the EIP **Return Address** pattern. The caller must
read the reply and react; DTW does not retry on the caller's behalf.

- Command topics (imperative): `…dtw.transaction.operations.*.command`; results on `…command-result`.
- Paired results per operation: `Entry*CommandSuccessResult` / `Entry*CommandFailureResult` (ledger,
  blocking, unblocking, reversal, removal, exclusion, release).

## Events — failure re-routes to a retry topic
Events keep DTW's many local copies in sync (registry → transaction → balance/statements; business-day
state → entrypoints). They are CDC / outbox messages, consumed fire-and-forget. If a consumer **can't
apply an event yet** — the classic case is a *create-account* event arriving **before its account-type has
been mirrored** — it must not block the partition or drop the message. Instead it **persists the event to
reroute tables and keeps reading**; a router later republishes it to a **retry topic**, where a retry
copy of the consumer re-attempts once the blocking condition clears.

## What is an "Aggregate"? (Domain-Driven Design)
An **Aggregate** is a cluster of related objects treated as **one consistency boundary**, with a single
**Aggregate Root** as the only external entry point. You load, save, audit, version — and **route** — the
whole cluster through its root. `Account` is an aggregate root; its features and limits are members.

- **Literature:** Eric Evans, **Domain-Driven Design** (2003), ch. "Aggregates"; Vaughn Vernon,
  **Implementing Domain-Driven Design** (2013) and **Effective Aggregate Design** (2011).
- **Why DTW cares:** change-capture is **aggregate-scoped, not row-scoped** (Hibernate Envers revisions
  are keyed to the aggregate root). So the **aggregate type is the label a failed event carries**, and it
  is exactly the key the retry router routes on.
- **How a type is declared:** the aggregate type is a discriminator string on the entity mapping
  (`@Any` + `@AnyDiscriminatorValue`, stored in the `AGGREGATE_ROOT_TYPE` column). That string *is* the
  aggregate type the router sees.

Aggregate-root types by service (the valid routing keys):

| Service | Aggregate-root types |
|---|---|
| registry | `ACCOUNT`, `ACCOUNT_TYPE`, `LIMIT_TYPE`, `HISTORY_CODE`, `PARTY`, `SYSTEM`, `GENERIC_DOMAIN` |
| transaction | `ACCOUNT`, `ACCOUNT_TYPE`, `HISTORY_CODE`, `SYSTEM_LIMIT`, `HOLD_REASON`, `UNSUPPORTED_GENERIC_DOMAIN`, `ACCOUNT_ENTRY` |
| balance / statements | `ACCOUNT`, `ACCOUNT_TYPE`, `HISTORY`, `HOLD_REASON`, `ACCOUNT_REG` |
| entrypoints | `SYSTEM_STATE` |

> **Same entity, two discriminators (`ACCOUNT_ENTRY`, `ACCOUNT_REG`).** A service that consumes the same
> `Account` entity from **two different sources/topics** gives it a **second** discriminator so the two
> failure streams can be routed to **different** retry topics. Route by *source*, not just by entity type.

## Which aggregates return to sender vs re-route
Classify by the **message kind of the hop**, not by the aggregate — the same aggregate can be a command
subject on one hop and an event subject on the next.

| Path | Kind | Aggregates / operations | On failure |
|---|---|---|---|
| A | Command | `Entry` (ledger/blocking/unblocking/reversal/removal/exclusion/release), `Bundle`, monitoring `Intent` | failure result → sender |
| B | Event | `Account`, `AccountType`, `HistoryCode`, `SystemConfiguration`, `GenericDomain`, `HoldReason`, `LimitType`, `Party`, `System`, `Transaction`, `SystemState` | re-route → retry topic |

## The retry flow, end-to-end
1. A **consumer** reads its regular topic. On a blocking condition, the event-error-handling layer
   **persists the event to the reroute tables** (`<PREFIX>_EVENT_ROUTING`, `<PREFIX>_EVENT_ROUTING_DATA`)
   and the consumer **keeps reading** — the partition never stalls.
2. A **router** (deployed once per sub-system) **polls** those tables and publishes each entry to the
   **retry topic mapped to its aggregate type**. The choice is purely `routing-topics.get(aggregateType)`;
   an **unmapped type is not re-routed** (resolves to null).
3. The **same consumer, deployed a second time under a `retry` profile**, listens on the retry topic and
   re-attempts. Same code, different topic binding. So every mirroring consumer runs as **two
   deployments**: `regular` + `retry`.

## Two topic systems — routed by different keys
Don't conflate them:

| | Command topics | Retry topics |
|---|---|---|
| Carries | commands (+ replies) | re-routed failed events |
| Routed by | **operation family** (fixed topic); reply → `ReplyKey` | **aggregate type** (`routing-topics`) |
| Example | `…transaction.operations.*.command` | `…transaction.retry.event.account` |

## Where retry routing is configured
- **Router:** `application.routing-topics` = map of `aggregateType → retryTopic`, supplied at deploy time
  (Kubernetes ConfigMap or `SPRING_APPLICATION_JSON`). At least one entry is required. Its keyset also
  defines which aggregate types get polled.
- **Consumer:** a `retry` profile rebinds the topic property to the `…retry.event.…` name and switches the
  consumer bridge into retry mode; the reroute tables are named per sub-system prefix.
- **Registry vs transaction are already separate** because the router runs **once per sub-system** — each
  has its own `routing-topics` map and its own `…registry.retry.…` / `…transaction.retry.…` namespace.
  Within a service, you split further **per aggregate** by giving each type its own topic in the map.

## The same rule at the core-banking boundary
When DTW has **already approved** a transaction and it must be replicated **outbound to a legacy core**,
that transaction is a **done fact**. If the core can't digest it (down, in batch, validation mismatch),
the core adaptor must **not** reverse the already-committed ledger entry. It applies the **same
retry-topic pattern on its own side**: persist the failed hand-off and re-attempt from a core-adaptor
retry topic until the core accepts it (or a human intervenes). The inbound approval was a *command*
(reply to sender); the outbound replication is an *event* (fact) → **retry, not return-to-sender**.

> **Explaining to humans:** lead with the one-sentence rule (command → error to sender; event → retry
> topic), then "aggregate = a DDD consistency boundary; DTW routes failed events by aggregate type."
> Keep `@AnyDiscriminatorValue` and table names for engineers who ask for the mechanism.

---

# Deep reference (for agents — full detail)

## Command payload shape (`EntryRequestCommand`)
```avro
EntryRequestCommand {
  baseCommand : { idempotencyId, traceId, correlationId, timestamp }
  account     : union { AccountKey{branch, account}, AccountGroupKey{kind, membershipId} }
  entry       : LedgerEntryRequestData { historyCode, amount, settlement, uponCreationHeldAs }
                // entry is a union: Ledger | Blocking | Unblocking | Reversal | Removal | Exclusion | Release
}
CommandKey {
  target : union { AccountKey, AccountGroupKey, ReplyKey }   // ReplyKey == EIP "Return Address"
  source : string                                            // which system requested the entry
}
```
- **Multi-account / composite** uses `BundleRequestCommand` (a bundle of legs); the composite service
  emits one ledger command per leg and consumes the per-leg results to advance the bundle saga.
- **Force-post** = a ledger entry with the `UNCONDITIONAL` update strategy (Kafka path only; allow-listed).

## Concrete topics by service (as coded)
Naming convention: regular = `[ENV].dtw.<source-module>.event.<subject>`; retry =
`[ENV].dtw.<source-module>.retry.event.<subject>`. `<prefix>` = `application.kafka.topics.prefix` (env id).

| Service · consumer | Regular topic (consume) | Retry topic |
|---|---|---|
| registry · account | `…legacy-adapter.event.account` | `<prefix>.dtw.registry.retry.event.account` |
| registry · config | `…legacy-adapter.event.{account-type,history-code,system-configuration,generic-domain}` | `<prefix>.dtw.registry.retry.event.config.*` |
| transaction · account | `<prefix>.dtw.registry.event.account` | `<prefix>.dtw.transaction.retry.event.account` |
| transaction · config | `<prefix>.dtw.registry.event.config.{account-type,history-code,system-configuration,generic-domain}` | `<prefix>.dtw.transaction.retry.event.config.*` |
| transaction · transaction | `<prefix>.dtw.transaction.event.transaction` | `<prefix>.dtw.transaction.retry.transaction.event` |
| balance/statements · account | `<prefix>.dtw.registry.event.account` | `<prefix>.dtw.balance.statement.retry.event.account` |
| balance/statements · config | `<prefix>.dtw.registry.event.config.*` | `<prefix>.dtw.balance.statement.retry.event.config.*` |
| balance/statements · transaction | `<prefix>.dtw.transaction.event.transaction` | `<prefix>.dtw.balance.statement.retry.event.transaction` |
| entrypoints · system-state | `<prefix>.checking-account.event.config.system-state` | `<prefix>.dtw.entrypoints.retry.event.config.system-state` |
| composite · command-result | `<prefix>.dtw.composite-transaction.operations.command-result` | `<prefix>.dtw.composite-transaction.retry.operations.command-result` |

Note the last row: the composite saga consumes **command results as events** to advance the bundle, so
*that* consume can itself re-route — a message can be a command reply on one hop and an event on the next.

## Router config (`application.routing-topics`)
The map is **not** in `application.yml`; it is deploy-time config. The router binds it as
`Map<String,String> routingTopics` (`@ConfigurationProperties("application")`, `@NotEmpty`); it exposes
`aggregatesToProcess() = routingTopics.keySet()` (what gets polled) and
`topicFor(type) = routingTopics.get(type)` (null when unmapped → not re-routed).

Kubernetes ConfigMap:
```yaml
data:
  application.routing-topics.ACCOUNT:      <prefix>.dtw.transaction.retry.event.account
  application.routing-topics.ACCOUNT_TYPE: <prefix>.dtw.transaction.retry.event.config.account-type
  application.routing-topics.HISTORY_CODE: <prefix>.dtw.transaction.retry.event.config.history-code
```
docker-compose / plain env (only option without K8s):
```
SPRING_APPLICATION_JSON='{"application":{"routing-topics":{"ACCOUNT":"…retry.event.account","ACCOUNT_TYPE":"…retry.event.config.account-type"}}}'
```
Per-sub-system identity (makes registry's router ≠ transaction's router): `application.sub-system.table-prefix`,
`application.sub-system.topic-qualifier`, `application.kafka.topics.prefix`. The router is internally a
two-stage polling-CDC pipeline (poller → private intermediate topic → enrich → retry topic).

## Consumer retry config (per module)
`application-kafka.yml` — the `retry` profile rebinds the topic and flips the bridge:
```yaml
spring.config.activate.on-profile: retry
application.kafka.topics.registry-account-events: ${application.kafka.topics.prefix}.dtw.transaction.retry.event.account
dtw-common.consumer-bridge.mode: retry
```
`application.yml` — the reroute (event-error-handling) tables, named per sub-system prefix:
```yaml
dtw-common.event-error-handling.routing-table-name:      DTW_TR_EVENT_ROUTING
dtw-common.event-error-handling.routing-data-table-name: DTW_TR_EVENT_ROUTING_DATA
```
Reroute-table prefixes per service: registry `DTW_REG_EVENT_ROUTING(_DATA)`, transaction
`DTW_TR_EVENT_ROUTING(_DATA)`; the router's own polling/lock tables follow its `sub-system.table-prefix`
(`<PREFIX>_POLLING_ENTRY`, `<PREFIX>_EVENT_ROUTING(_DATA)`, `<PREFIX>_LOCK_ENTRY`). Consumer de-dup is a
per-service `*_IDEMPOTENCY_ENTRY` table (at-least-once delivery → idempotent apply).

## Delivery & ordering guarantees to reason with
- **At-least-once** consume + **idempotency table** = effectively-once apply; re-deliveries are skipped.
- Reroute **preserves progress**: blocked events leave the main partition immediately (persisted to the
  reroute tables), so head-of-line blocking never happens; ordering is restored per aggregate on retry.
- **Unmapped aggregate = silently not re-routed.** If you expect retries for an aggregate and see none,
  check that its type is a key in `routing-topics` and that a `retry`-profile consumer is listening.
