A mutation can require an expected version so a second writer cannot silently overwrite a changed receipt.
Spring GraphQL mutation versions: reject a stale stock reservation
Make the conflict a data rule
The checked mutation reserves seven units from RC-47 at version 3, leaving 12 units and version 4. A second request carrying expectedVersion 3 produces a GraphQL error and leaves the balance at 12. The in-memory ledger uses ConcurrentHashMap.computeIfPresent to make that one-process read-check-write atomic. A deployed database needs a conditional UPDATE with tenant, ID, version and stock floor in the predicate, followed by an affected-row check. The JDBC lesson tests that database contract separately.
A wrong-tenant mutation returns null and leaves RC-47 unchanged. The test injects tenant identity into GraphQL context itself; no authentication filter validates it. Tenant resolver boundaries explain why that source of identity must change before deployment.
Keep side effects outside a failed write
If the mutation also emits an event, the event intent must be committed with the database change or published from a durable outbox. Sending to a broker before the versioned update succeeds can announce a reservation that never happened. The outbox transaction and broker handoff cover those cut points.
Checked excerpt
Receipt result = ledger.computeIfPresent(id, (ignored, current) -> {
if (!current.tenant().equals(tenant(environment))) return current;
if (current.version() != expectedVersion) {
throw new IllegalStateException("stale receipt version");
}
if (current.available() < units) {
throw new IllegalStateException("insufficient stock");
}
return new Receipt(current.id(), current.tenant(),
current.available() - units, current.version() + 1);
});Verification boundary
The Java 21 / Spring Boot 4 source kit checks mutationUsesVersionAndDecrementsOnce, staleMutationReturnsErrorWithoutAnotherDebit and wrongTenantMutationDoesNotChangeTarget.
Cost and limits
The local map update serializes writes per key within one process. It has no persistence, cross-node conflict control or database transaction. A conditional SQL update needs one round trip plus indexed row access; contention and retries depend on the target database.
Common Mistakes
- Do not check the version in one query and update in a later unguarded query.
- Do not publish an event before confirming the mutation committed.
- Do not treat a test-supplied tenant context as authenticated identity.
Read next
Spring JDBC versioned tenant update: inspect the affected row count, Spring GraphQL tenant reads: scope every resolver before returning a record, Spring transactional outbox: commit a receipt and event row together, Spring outbox to Kafka: name the crash window between send and relay acknowledgement, Spring GraphQL errors: distinguish validation, resolver failure and nullable data.
Reactive database continuation
Continue with Spring R2DBC conditional update: test tenant, version and stock floor in one statement.
