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 @ProjectionListener methods idempotent: applying the same event twice must leave the read model in the same state as applying it once. Write-side upsert-style operations (update-or-create keyed on a stable id) satisfy this naturally.

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 @IgnoreOnReplay. See Building projections for the full listener-method reference.

@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.

@IgnoreOnReplay takes an optional condition — a SpEL expression evaluated against the incoming event (exposed as #event). When blank (the default) the method is always suppressed during replay; when set, it is suppressed only when the expression evaluates to true.

@IgnoreOnReplay(condition = "#event.amount > 1000")
@ProjectionListener
fun onLargeDeposit(event: MoneyDeposited) { … }

Use property access and SpEL operators only: the evaluation context has no BeanResolver, so bean references (@bean.method()) are not supported and throw at runtime. This is the same SpEL convention @AdHocUpstream uses for its key expression.

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.

Catch-up frontier — the checkpoint advances only over the contiguous applied prefix of the event log
Figure 1. How the checkpoint follows the frontier

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

spring.ddd.cqrs.projection.executor.concurrency-limit

Available processors

Worker threads in the shared keyed executor (same id serial, different ids parallel).

spring.ddd.cqrs.projection.executor.queue-capacity

1024

Per-worker bounded queue capacity; bounds backpressure per read-model partition.

spring.ddd.cqrs.projection.optimistic-lock.retry.max-attempts

3

Max attempts when a read-model write loses an optimistic-lock race. After exhausting retries, the failure propagates to the error policy.

spring.ddd.cqrs.projection.optimistic-lock.retry.delay

50ms

Base jittered backoff between optimistic-lock retries.

spring.ddd.cqrs.projection.consumer.polling.interval

1s

Wake/poll cadence; the consumer also wakes on every after-commit signal.

spring.ddd.cqrs.projection.consumer.lease-ttl

1m

Single-consumer lease TTL; failover latency ≈ this value.

spring.ddd.cqrs.projection.consumer.polling.batch-size

1000

Max source-position window scanned per pass; bounds per-pass work.

spring.ddd.cqrs.projection.consumer.max-in-flight

256

Max log positions dispatched ahead of the durable checkpoint before awaiting completions.

spring.ddd.cqrs.projection.consumer.retry.max-attempts

3

Total attempts (1 initial + retries) to apply a single event before the error policy fires.

spring.ddd.cqrs.projection.consumer.retry.delay

100ms

Base backoff before the first apply retry.

spring.ddd.cqrs.projection.consumer.retry.delay-multiplier

1.0

Exponential growth factor for apply-retry backoff (1.0 = fixed delay).

spring.ddd.cqrs.projection.consumer.retry.max-delay

5s

Upper bound on the (multiplied) apply-retry backoff.

spring.ddd.cqrs.projection.consumer.stall-warn-interval

30s

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 a replay to 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 replay is the only way to reprocess it. Use SKIP only 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.

@InMemoryProjection is permanently pinned to HALT regardless of its onError attribute or the application-wide default.

Projection state

ProjectionState exposes the operator-facing lifecycle:

State Meaning

RUNNING

Consuming normally; may be catching up or live.

REPLAYING

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 @Partitioned projection it also covers the quiesce that precedes the reset (HaltReason.REBUILD): the projection is paused, and the generation is bumped and the partition cursors wiped only when that pause is released.

HALTED

Stopped by an unrecoverable apply failure (HaltReason.ERROR). Requires operator intervention.

SUSPENDED

Deliberately stopped by an operator call to ProjectionManager.halt() (HaltReason.MANUAL).

Halt, resume & rebuild

Projection state lifecycle: RUNNING moves to REPLAYING during catch-up or a replay rebuild
Figure 2. A projection’s states and the transitions between them

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 @Partitioned projection replay bumps nothing directly: it records the rebuild horizon and a durable REBUILD pause, and the checkpoint in PROJECTION_METADATA is 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 REBUILD pause of a @Partitioned projection: that pause belongs to the rebuild and is released only by the rebuild itself, as part of resetting the epoch. resume refuses 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 resume to lift it.

On application restart, projections with an error halt (HALTED) auto-resume on the first pass — they will retry the failing event immediately. Projections with a manual halt (SUSPENDED) do not auto-resume; the operator must call resume explicitly. A @Partitioned projection quiesced for a rebuild (REPLAYING, HaltReason.REBUILD) does not auto-resume either — the pause survives the restart intact and is lifted by the rebuild when it resets the projection’s epoch.

@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.