Atomic Writes
Atomic writes commit events to several aggregates at once, each with its own concurrency check. Either every event lands or none do.
You probably don't need this. The aggregate is your consistency boundary — if you find yourself reaching for atomic writes often, the boundaries are drawn in the wrong place. Most applications never use this endpoint. Read when to use it before reaching for it.
When to use it
The deciding question is: can the second write fail in a way that should mean the first never happened?
| Answer | Use |
|---|---|
| No — the second thing follows from the first | An implication |
| No, but it must eventually happen and may need retrying | A saga |
| Yes — the second thing can veto the first | An atomic write |
An implication is a consequence. The parent event is already true, and the implied event follows from it. Implications expand after the parent has been written, so a failing implication cannot un-write its parent.
An atomic write is a guard. The parent isn't true until the other writes are possible.
The case it exists for
A booking needs two calendar slots. If either slot is already taken, the booking must not exist.
Written as separate calls, whichever you write first can survive the failure of the second. Written as an implication, the booking lands and then the hold fails, leaving a booking for a slot you don't hold. Written atomically, the question doesn't arise.
Cases it does not fit
Invoicing is the common near-miss. When voiding an invoice, the state change must win the transition and the ledger entry follows — but the ledger write cannot veto the void. A void that already won shouldn't be undone because bookkeeping hiccupped. That is a consequence, not a guard, and retry is the right tool.
Request
POST /_atomic-write
{
"targets": [
{
"aggregate_type": "booking",
"id": "550e8400-e29b-41d4-a716-446655440001",
"previous_length": 0,
"events": [
{ "type": "was_created", "data": { "slot": "0900" } }
]
},
{
"aggregate_type": "room_calendar",
"id": "550e8400-e29b-41d4-a716-446655440002",
"previous_length": 7,
"events": [
{ "type": "had_slot_held", "data": { "0900": "550e8400-e29b-41d4-a716-446655440001" } }
]
}
],
"metadata": {
"actor": { "type": "user", "id": "550e8400-e29b-41d4-a716-446655440099" }
}
}
targets (required)
Between 1 and 10 targets. Each has:
| Field | Required | Description |
|---|---|---|
aggregate_type |
yes | Must exist in your spec |
id |
yes | Any j17 identifier |
events |
yes | Non-empty array of { "type", "data" } |
previous_length |
no | This target's own concurrency check |
previous_length is per target. Each aggregate is checked against the length you read for it. Omit it to mean "however long it is now", which cannot conflict.
The same aggregate may not appear twice — two targets naming one stream have no unambiguous order, and picking one would silently drop events.
metadata (required)
Applies to every event in the write. Same contract as writing events: actor is required, target is optional, and unknown fields are refused.
Response
{
"ok": true,
"written": 2,
"stream_ids": {
"booking:550e8400-e29b-41d4-a716-446655440001": ["1738000000000-0"],
"room_calendar:550e8400-e29b-41d4-a716-446655440002": ["1738000000001-0"]
}
}
201 Created. stream_ids is keyed by aggregate; written is the total event count.
Concurrency conflicts
If any target's previous_length doesn't match, the whole write is refused with 409 Conflict and nothing is written:
{
"ok": false,
"error": "occ_conflict",
"key": "room_calendar:550e8400-e29b-41d4-a716-446655440002",
"current_length": 8,
"expected_length": 7
}
key names the aggregate that moved, so you know what to re-read. Since nothing was written, retry the whole set: re-read the aggregates, re-evaluate your decision, and submit again.
Per-aggregate retry has to go
If you're converting code that wrote these aggregates one at a time, it probably has a retry helper that re-reads one aggregate on conflict and tries again. Delete it rather than adapt it.
Retrying a single target is meaningless here. The conflict says the set you decided against is no longer the set that exists, and a sibling target's previous_length may have gone stale in the same instant — so re-reading only the aggregate named in key can produce a request that's already wrong when you send it. Worse, the decision itself may no longer hold: the point of the guard is that the other writes are possible, and a conflict is exactly the evidence that they might not be.
Retry belongs to whoever holds the whole set, which is the caller that assembled the targets. Re-read all of them, re-run the decision, and submit again — or don't, if the decision no longer comes out the same way.
What the log records
Each conditioned target's first event carries metadata.previous_length — the value that target was checked against. Events of an unconditioned target carry no previous_length field at all.
Absent and zero are deliberately distinguishable: 0 is itself a meaningful precondition ("this stream must be empty"), so an event whose metadata says previous_length: 0 was arbitrated against an empty stream, and an event without the field was written unconditioned. Anyone auditing a stream later can tell which writes were guarded without reconstructing the request that made them.
Idempotency
Send X-Idempotency-Key exactly as you would for a single write. A repeated key returns the original response rather than writing again.
This matters more here than elsewhere. If your request times out, you cannot tell a refusal from a lost reply — and retrying blind either books twice or conflicts against a check your own first attempt already consumed. Send a key.
Limits
Ten targets. An atomic write holds your instance's storage for its duration, so the set has to stay small. If you need more than ten aggregates in one decision, that's a strong sign the aggregates are drawn too finely.
Restrictions
Events that declare implications are refused. An implication writes further events inside the same request that are not covered by the all-or-nothing guarantee, so you would get two different promises from one call with nothing telling you which events had which. Rather than hand back a weaker guarantee than the one you asked for, the write is refused:
{
"ok": false,
"error": "implications_in_multi_write",
"target_index": 1,
"message": "student_calendar.was_archived declares implications, which a multi-aggregate write does not expand..."
}
Either write those events individually, or list the implied aggregates as explicit targets so they're covered by the same guarantee.
Sagas are unaffected. A saga writes nothing during your request — it's picked up afterwards, exactly as for an ordinary write — so events written atomically trigger sagas normally.
Errors
| Status | Meaning |
|---|---|
409 |
A target's previous_length didn't match. Nothing written; re-read and retry. |
422 |
Validation failed, more than 10 targets, a duplicate aggregate, or an event declaring implications. target_index points at the offending target when one is identifiable. |
429 |
Rate limited. |
Validation runs across every target before anything is written, so a 422 never leaves a partial write behind.
Billing
Atomic writes are metered per event, like every other write. A three-target write of one event each counts as three events. Grouping writes together doesn't change what you're charged.
See also
- Writing events - the ordinary single-event write
- Batch operations - many events, one aggregate
- Implications - consequences, not guards
- Atomicity concepts - OCC and consistency