Atomic Operations¶
The JSON:API Atomic Operations extension lets a client send a batch of write operations in one request, applied in order and all or nothing: either every operation commits, or the first failure rolls the whole batch back. This page describes the model the library supplies — the wire contract, the framework-agnostic loop, and the seam an integration implements to run it. It is conceptual; for the Symfony endpoint, configuration and a worked example, see the bundle's Atomic Operations guide.
The extension at a glance¶
The extension is identified by a single canonical URI,
https://jsonapi.org/ext/atomic
(AtomicExtension::URI), advertised in — and
matched against — the ext media-type parameter:
Content-Type: application/vnd.api+json; ext="https://jsonapi.org/ext/atomic"
Accept: application/vnd.api+json; ext="https://jsonapi.org/ext/atomic"
It claims one reserved member-name prefix, atomic: the request document carries an
atomic:operations array, and a successful response carries an
atomic:results array. An integration exposes a single endpoint for the
batch (the spec's POST /operations), distinct from the per-type CRUD endpoints.
The request¶
atomic:operations is an ordered, non-empty array of operation objects. Each carries:
- an
op—add,update, orremove(AtomicOperationCode).addcreates a resource or adds members to a to-many relationship;updatereplaces a resource or a relationship;removedeletes a resource or removes members from a to-many relationship. Whether an operation is a resource operation or a relationship operation is carried by its target'srelationship, not by the code; - a target — exactly one of a structural
ref(Ref: atype, exactly one ofid/lid, and an optionalrelationship) or ahrefstring (a URL the integration matches against its routes). A resourceaddmay carry neither — its target is its owndata.type; - a
datapayload — a resource object (anadd/updateof a resource), a single resource-identifier ornull(a to-one relationshipupdate), or a list of resource-identifiers (a to-many relationship operation). Aremoveof a resource carries nodata.
The response¶
A fully successful batch is a 200 OK document whose sole primary member is
atomic:results — an ordered array with one result object per operation, in
request order. Per the spec a result object carries only data and/or
meta (the created/updated resource or identifier); it is never allowed
links or included. An operation with nothing to return — a remove, or an
update the server fully applied with no body — contributes an empty result
object {}
(AtomicResult,
AtomicResultsResponse). The response
advertises the extension on its Content-Type ext parameter.
Local ids (lid)¶
Because operations run in order, a later one can reference a resource an earlier one
created — before the server id exists — by a client-assigned local id (lid).
A create carries a lid; a later operation references it (in a ref, or in
relationship linkage), and the executor resolves it to the real, server-assigned id
once the create has run. The LocalIdRegistry
holds the (type, lid) → id map for the batch: a reference to an unregistered lid
is a LocalIdNotFound (400), a duplicate
(type, lid) a LocalIdConflict (400).
References are backward-only — a lid must be registered by an earlier
operation in the same batch.
Framework-agnostic by construction¶
The library owns the extension's framework- and storage-agnostic semantics; an integration supplies only what is framework-specific (the endpoint, request detection, transactions). Three pieces make up the core foundation:
-
The parser.
AtomicOperationsParserturns a decoded request document into an ordered list ofOperationDescriptors. It is purely structural: it checks theatomic:operationsarray is present and non-empty, each operation has a knownop, exactly one ofref/href(or neither, only for a resourceadd), a structurally validref, and adatashape appropriate to the code. Every failure is anAtomicOperationsInvalid(400) carrying asource.pointerto the offending member. Semantic validation — whether atypeis registered, a relationship exists, alidresolves — is not the parser's job; that is execution-time work, so the parser touches no registry or storage and runs in the library alone. -
The loop.
AtomicLoopis the all-or-nothing driver. Given the parsed descriptors and anAtomicLoopBackendInterface, it opens the boundary (begin()), applies each operation in order (executeOne(), threading one sharedLocalIdRegistry), thencommit()s and returns anAtomicResultsResponse. The first operation — or the commit itself — that throws aJsonApiExceptionInterfacetriggers arollback()and a singleErrorResponse, with each error'ssource.pointerprefixed by the failing operation's index (/atomic:operations/<i>+ the inner pointer; an error with no pointer is located at/atomic:operations/<i>). Any other\Throwablerolls back and is left to propagate. -
The backend seam.
AtomicLoopBackendInterfaceis the four-method contract the integration implements —begin(),executeOne(),commit(),rollback(). The library knows nothing about transactions or persistence; the backend supplies them. This is what keeps the loop reusable: a Symfony bundle drives it over a Doctrine transaction; a different integration could drive it over anything else.
Both the success and the rolled-back error document advertise the extension on their
Content-Type (a document produced under an applied extension must declare it).
Operations as an independent extension¶
The Atomic Operations extension defines exactly three operation codes — add,
update, remove. A natural question is whether you can add another operation
code to a batch — say, to invoke a custom action inside the same all-or-nothing
request. You cannot do this by extending ext=atomic.
A JSON:API extension is an immutable, versioned contract identified by its URI. The
set of operation codes, and the members the extension reserves under its atomic:
namespace, are part of that contract. Adding a new op value, or a new member, to
ext=atomic would change the meaning of that URI — so a document a client sent
under ext="https://jsonapi.org/ext/atomic" would no longer mean the same thing to
two servers. That is precisely what the extension mechanism exists to prevent.
The spec-correct way to add operation codes is therefore a separate, independent
extension under its own ext URI — which:
- defines its own operation vocabulary (its own
opvalues and any reserved members) under its own namespace, leavingext=atomic's enum and members untouched; - is negotiated independently — a request opts into it by listing its URI in the
extmedia-type parameter (alongsideext=atomicif it wants both); - is never a profile. A profile may add semantics a processor is free to ignore (an unrecognised profile is harmless); it may not introduce new document members or change processing rules. New operation codes change how a document is processed, so they are extension territory, not profile territory.
In short: the operation set is closed for ext=atomic; new operations live in their
own extension with its own URI. The library's foundation supports this cleanly — the
parser, loop, and backend seam are all keyed on the atomic vocabulary, and a new
extension brings its own parser/loop or its own backend without mutating any of
them.
See also¶
- The bundle's Atomic Operations guide — the Symfony endpoint, configuration, transactions, lifecycle hooks, and a worked example.
- Operations and dispatch — the CRUD operation VOs a batch's sub-operations are turned into.
- Profiles — the other extensibility mechanism, and why it is not the place for new operations.