Correlation IDs and read-your-writes
Suppose an API receives a request to transfer 30 from Alice to Bob. The request produces a command, a stored event, a projection update, several log entries, and saga work. Those messages happen at different times and may run on different nodes. A correlation id, usually shortened to CID, marks them as parts of the same request flow.
The CID answers “which request caused this work?” It does not answer “which account owns this state?”
Alice's aggregate id remains alice. A single CID can pass through several aggregate identities, while
many requests for Alice's account each receive their own CID.
aggregate id: alice alice bob
correlation id: request-A -> request-A -> request-A
SendTransfer TransferSent ReceiveTransfer (saga command)
Follow one CID through the system
The application creates a CID at the boundary where one logical request begins. FCQRS carries it from the command to the resulting event. Saga commands and their events retain the same request context, and projection notifications expose it to subscribers.
That continuity supports two jobs:
- logs and traces can group activity produced by one request;
- an active caller can wait for the projection update produced by its command.
A CID is context, not a correctness guarantee. It does not serialize commands, deduplicate retries, make delivery exactly once, or prove that every projection is current. Aggregate boundaries, idempotency rules, journal writes, and projection progress provide those guarantees separately.
Why a successful command can still look stale
An aggregate replies after its event is stored. A projection consumes that event asynchronously, so a query sent immediately after the reply may still see the previous read model.
command -> aggregate -> journal -> projection -> read model
^ durable ^ may happen later
This is expected CQRS behaviour. The write side is current before the read side catches up.
Motivation: Most requests can tolerate that short delay, so FCQRS does not make every write wait for every projection. Read-your-writes adds coordination only to the interaction that needs an immediately current view.
When the caller must read its own write, FCQRS uses the CID as a request-scoped rendezvous:
- Create a CID.
- Subscribe to the required projection's notifications for that CID.
- Send the command with the same CID.
- Wait while the aggregate stores the event and the projection commits its update.
- Receive the projection notification.
- Query that projection's read model.
Subscribe before sending
Projection notifications are live signals. They are not retained for a caller that arrives later. If the application sends first, a fast projection can publish before the subscription exists and the caller can wait indefinitely for a signal that already passed.
Subscribing first removes that race:
unsafe: send -------- projection publishes -------- subscribe
safe: subscribe --- send --- projection publishes --- wait completes
The journal event is durable. The notification is deliberately ephemeral. It
coordinates one active request; it is not a queue for disconnected clients. Bound the wait so the
request has a defined outcome if the projection cannot catch up: the F# sendAwaiting helper is
bounded by akka.fcqrs.command-timeout (default 30s) and raises TimeoutException; raw subscriptions
and the C# awaiter take an explicit timeout or cancellation token.
Wait for the projection you will query
A notification means that the projection publishing it has finished handling the matching event. It says nothing about another projection with its own progress.
For example, a statement projection and a monthly-report projection may consume the same event at different speeds. If the response queries only the statement, wait for that projection. If it combines both models, wait for a completion signal from both or create one application-level signal that represents the combined requirement.
One CID may also produce several events. A saga can cross aggregates, and one command can persist a batch. Select the event that makes the required query safe, or wait for the correct number of matching notifications. “First event with this CID” is correct only when the first event is the boundary the caller needs.
One saga start per correlation ID and aggregate
FCQRS names a saga after the aggregate whose event starts it and that event's CID. Alice's transfer
with CID request-A runs as the saga alice~Saga~request-A. The saga keeps the event that started it
for as long as its history exists, also after it finishes.
Another event that would start the same saga, from Alice's account with request-A, cannot get a
workflow of its own. FCQRS refuses the command that stores it: the aggregate stores none of the
command's events, and the caller's send fails with SagaAlreadyStartedException, which names the
aggregate, the CID, and the saga. Two situations lead there:
- one request sends Alice's account two
SendTransfercommands under the same CID; - one command stores two events that each start a transfer.
Give each command that can start a saga its own CID, including a retry of a command that failed. Keep retries safe with a domain ID the aggregate remembers, as the tutorial's transfer ID does, not with the CID. A command that stores several saga-starting events has to become several commands, or one saga that handles the whole batch.
Deferred replies do not reach projections
A deferred rejection such as Rejected "Insufficient funds" is returned to the command caller but is
not stored. Because it never enters the journal, no journal projection can publish a notification for it.
FCQRS stamps the delivered reply to distinguish the paths:
Journaled = Some true: a stored event can later reach a projection;Journaled = Some false: a deferred or publish-only reply will not reach a journal projection;Journaled = None: the envelope predates or bypassed that delivery stamp. The envelope is the command or event record that carries the payload, correlation id, and metadata.
The sendAwaiting helper checks this value and skips the projection wait for a non-journaled reply.
Without that check, rejection paths could wait for notifications that can never exist.
Keep the three identifiers separate
| Identifier | Scope | Question it answers |
|---|---|---|
| Aggregate id | one domain owner | Which account owns this decision? |
| Correlation id | one request flow | Which commands, events, logs, and notifications belong together? |
| Message id | one envelope | Which individual command or event is this? |
Reusing an aggregate id as a CID mixes concurrent requests for the same entity. Reusing one global CID mixes unrelated work. Create a fresh CID for each logical request and let FCQRS carry it through the work caused by that request.
Put it into practice
The tutorial's statement waits for each command's event before it
queries, and the transfer step waits for a saga's last event by
correlation ID. Read your writes provides the F# and C# APIs,
including filters, multiple notifications, cancellation, and sendAwaiting. Observe your
system shows how the same CID appears in logs and traces.