Delivery & error handling
The read side stays in sync with the write side through asynchronous, after-commit delivery. When a command changes aggregate state, the resulting events are delivered to each projection after the originating transaction commits — never inside it — and each apply runs in its own independent transaction. This design keeps the write side fast and the read side independently scalable, but it comes with two responsibilities: listeners must be idempotent, and failures must be surfaced and handled deliberately.
Delivery model
Every @ProjectionListener method runs after the originating transaction commits, in its own
REQUIRES_NEW transaction.
Spring DDD registers an afterCommit hook that wakes the projection consumer; the consumer
then applies the event to each affected read-model id in a fresh transaction.
At-least-once semantics: the checkpoint advances only after a successful apply. If the application restarts or the apply fails partway through, the same event position will be re-delivered on the next pass. A re-delivered position for the same generation is a no-op — the dispatcher skips it — so delivery is safe to retry without corrupting the read model, provided the listener is itself idempotent.
|
Make your If a listener method triggers a live-only side effect (sending a notification, publishing an
outbox message) that must not fire during a rebuild, annotate it with |
@JsonProjection(name = "account-summary")
class AccountSummaryProjection {
@InitReadModel
fun init(id: AccountId) = AccountSummary(id = id, balance = BigDecimal.ZERO)
@ProjectionListener
fun on(event: MoneyDeposited, summary: AccountSummary) { (1)
summary.balance += event.amount.value
}
// Live-only side effect: skipped during catch-up replay
@IgnoreOnReplay
@ProjectionListener
fun onDeposited(event: MoneyDeposited) { (2)
notificationService.notifyDeposit(event.id, event.amount.value)
}
}
| 1 | State-managing: mutate the read model the framework passes in — it saves that same instance afterwards. A listener’s return value, if any, is ignored, so update the read model in place (it must expose mutable properties) rather than returning a copy. |
| 2 | Side-effect only (no read-model parameter); @IgnoreOnReplay stops it firing during a rebuild. |
|
Use property access and SpEL operators only: the evaluation context has no |
Catch-up
A projection that falls behind — because the application was down, a new projection was added, or a rebuild was triggered — catches up by replaying all event positions from its last checkpoint up to the current high-water mark.
Ordering guarantees during catch-up mirror those during live delivery:
-
Events for one read-model id apply in order (serially within that id’s partition).
-
Events for different ids apply in parallel (across the shared keyed executor).
The checkpoint advances only over the contiguous, fully-applied prefix.
If id A at position 42 finishes before id B at position 41, the checkpoint waits for id
B before advancing past 41.
This frontier is tracked internally and ensures the checkpoint never skips an unconfirmed apply.
Single-active per projection across nodes (durable stores): for a persistent
@JsonProjection / @JpaProjection, the consumer acquires a lease (recorded in the projection
metadata store) before processing.
Only one node per projection may hold the lease at a time; other nodes back off and retry.
This prevents duplicate catch-up work and concurrent checkpoint conflicts in a multi-node
deployment.
This does not apply to @InMemoryProjection: its read models and checkpoint are JVM-local, so
every node rebuilds its own copy independently with no shared lease — see
In-memory projections.
Concurrency & retries
The table below lists the key tuning properties. See Configuration properties for the full list.
| Property | Default | Purpose |
|---|---|---|
|
Available processors |
Worker threads in the shared keyed executor (same id serial, different ids parallel). |
|
|
Per-worker bounded queue capacity; bounds backpressure per read-model partition. |
|
|
Max attempts when a read-model write loses an optimistic-lock race. After exhausting retries, the failure propagates to the error policy. |
|
|
Base jittered backoff between optimistic-lock retries. |
|
|
Wake/poll cadence; the consumer also wakes on every after-commit signal. |
|
|
Single-consumer lease TTL; failover latency ≈ this value. |
|
|
Max source-position window scanned per pass; bounds per-pass work. |
|
|
Max log positions dispatched ahead of the durable checkpoint before awaiting completions. |
|
|
Total attempts (1 initial + retries) to apply a single event before the error policy fires. |
|
|
Base backoff before the first apply retry. |
|
|
Exponential growth factor for apply-retry backoff ( |
|
|
Upper bound on the (multiplied) apply-retry backoff. |
|
|
How long a catch-up apply may block before a liveness warning is logged (diagnostic only; the consumer keeps waiting). |
Optimistic-lock conflicts on a read-model write retry up to
optimistic-lock.retry.max-attempts (with full-jitter backoff), then propagate as an apply failure
and count against consumer.retry.max-attempts.
Error handling — onError
When the retry budget (consumer.retry.max-attempts) is exhausted, the projection’s error policy
determines what happens next.
Per-projection policy
Declare the policy on the projection annotation:
@JsonProjection(onError = OnError.SKIP) // or HALT (default) or INHERIT
class AccountSummaryProjection { ... }
HALT(default)-
Stop only this projection. The checkpoint is held just before the failing event position and the halt is recorded durably. All other projections continue running unaffected. An operator must investigate and either fix the underlying data / code, then call
resume, or trigger areplayto rebuild from scratch. SKIP-
Log the failure, skip past the poison event, and keep consuming. The bad event is permanently lost from this projection’s view; a subsequent
replayis the only way to reprocess it. UseSKIPonly when occasional data loss in the read model is acceptable and an alert/log review is in place. INHERIT-
Resolve to the application-wide default (
spring.ddd.cqrs.projection.default-on-error). This is the annotation’s default value; it is resolved at registration time and never reaches the runtime.
Application-wide default
Set spring.ddd.cqrs.projection.default-on-error to HALT or SKIP (default is HALT).
Setting it to INHERIT is rejected at startup.
|
|
Projection state
ProjectionState exposes the operator-facing lifecycle:
| State | Meaning |
|---|---|
|
Consuming normally; may be catching up or live. |
|
A full rebuild is in progress.
For a single-owner projection that means the generation is bumped and the checkpoint is back at 0.
For a |
|
Stopped by an unrecoverable apply failure ( |
|
Deliberately stopped by an operator call to |
Halt, resume & rebuild
ProjectionManager is the programmatic operations API:
replay(projectionClass)-
Triggers a full rebuild. For a single-owner projection it bumps the generation, resets the checkpoint to 0, and re-replays all events to reconstruct every read model. The reset is lazy and near-zero-downtime — existing rows are not wiped up front; each is reset on first touch as replay re-applies its events, so the projection keeps serving the previous generation’s data until the rebuild catches up.
For a
@Partitionedprojectionreplaybumps nothing directly: it records the rebuild horizon and a durableREBUILDpause, and the checkpoint inPROJECTION_METADATAis not what re-drains the projection. The epoch reset — wiping the partition cursors and minting the new generation — happens later and cluster-wide, once the pause has stood for at least one lease duration and every partitioned upstream in the closure has been reset; the bucket owners then re-drain their own buckets from cursor 0. See What an in-place partitioned rebuild resets.This is also the mechanism that per-store schema-evolution sections rely on — see JSON projections, JPA projections, and In-memory projections for store-specific guidance.
resume(projectionClass)-
Clears a durable error or manual halt and retries on the next consumer pass (within one poll interval). Use this after fixing the root cause (bad event data, a bug in the listener).
It does not clear the
REBUILDpause of a@Partitionedprojection: that pause belongs to the rebuild and is released only by the rebuild itself, as part of resetting the epoch.resumerefuses it rather than letting bucket owners drain on against the old epoch — there is nothing to resume by hand. halt(projectionClass)-
Records a durable manual halt at the current checkpoint. The consumer stops on its next pass and will not resume automatically — not even after a restart. Call
resumeto lift it.
|
On application restart, projections with an error halt ( |
@Component
class AccountSummaryOps(private val projectionManager: ProjectionManager) {
// After deploying a schema fix: clear the error halt and retry
fun retryAfterFix() {
projectionManager.resume(AccountSummaryProjection::class)
}
// Trigger a full rebuild (e.g. after a schema migration)
fun rebuild() {
projectionManager.replay(AccountSummaryProjection::class)
}
// Pause delivery while maintenance is in progress
fun suspendForMaintenance() {
projectionManager.halt(AccountSummaryProjection::class)
}
}
Read-model declaration changes
A projection’s read model can change independently of the events it consumes. A new @ProjectionListener,
an edited @ReadModelKey, or a different computation inside a listener does not touch a single stored
event — the event log stays exactly as it always did. What changes is how a projection derives its read
model from that log, and that kind of change is handled by rebuilding the projection.
A rebuild (replay, described above) resets the projection’s checkpoint and re-derives every read model
under the new declaration, from the event log or, for a projection-fed target, from its upstream’s
re-emitted current state.
A projection that consumes an upstream read model through a re-defining @ReadModelKey (see
Chaining projections and
Re-key edges) needs the same rebuild after either kind of
change: editing the @ReadModelKey itself, or a runtime re-key value move whose vacated old group must
be corrected immediately rather than waiting for it to matter — a re-key edge’s routing index does not
proactively re-signal a row it no longer resolves to. replay handles both directly, whether or not the
downstream is @Partitioned — a partitioned re-key downstream rebuilds in place, distributed across its
bucket owners, rather than on a single node — see
Rebuilding a partitioned re-key downstream for the full
procedure.
Operating projections
Inspecting projection status, lag, and halts — and triggering replay / resume / halt over
HTTP — is covered in Actuators.