Chaining projections

A projection’s read model does not have to be built solely from domain events. Spring DDD lets a downstream projection listen to the read-model type produced by an upstream projection — an enriched or aggregated read model that derives its state from another read model rather than, or in addition to, raw events.

A common use case is a branch-level or customer-level rollup that aggregates data from per-account read models. Because the downstream subscribes to the upstream’s read-model type, it stays up to date whenever the upstream saves a new version — without coupling the two projections' event listeners or schemas.

Projection chaining: an upstream projection with publishCurrentState publishes its read model as an event; a downstream projection listens for it and builds its own read model
Figure 1. A downstream projection consumes an upstream projection’s read model

Enabling chaining

To expose a read model for downstream consumption, set publishCurrentState = true on the upstream projection. Spring DDD then publishes the saved read model as a Spring application event after each live apply.

@JsonProjection(name = "account-summary", publishCurrentState = true) (1)
class AccountSummaryProjection {

    @InitReadModel
    fun init(id: AccountId) = AccountSummary(id = id, balance = BigDecimal.ZERO)

    @ProjectionListener
    fun on(event: MoneyDeposited, summary: AccountSummary) {
        summary.balance += event.amount.value
    }

    @ProjectionListener
    fun on(event: MoneyWithdrawn, summary: AccountSummary) {
        summary.balance -= event.amount.value
    }
}
1 Declares this projection as a chaining source. (Listeners mutate the read model in place; a returned value is ignored — see Building projections.)

The downstream projection declares a @ProjectionListener whose first parameter is the upstream’s read-model class (AccountSummary):

@JsonProjection(name = "branch-totals")
class BranchTotalsProjection {

    @ProjectionListener
    fun on(summary: AccountSummary, totals: BranchTotals) { (1)
        totals.totalBalance = summary.balance               (2)
    }
}
1 AccountSummary is the re-emitted upstream read model, not a domain event.
2 =, not += — see Read model: set. Event: fold. below for why.

Read-model id resolution for the downstream follows the same rules as for domain events — see Building projections for the full listener-method reference.

A downstream that is co-keyed with its upstream — its id resolves directly to the upstream row’s own id, with no @ReadModelKey in between — can additionally carry @Partitioned to scale its apply work across instances instead of running single-owner; see Feed mode for the rules and an example.

A downstream that is not co-keyed — one that re-keys the upstream onto a different id via a re-defining @ReadModelKey (a branch- or customer-level rollup, say, keyed by branchId rather than the upstream’s own account id) — can carry @Partitioned too: the framework routes each upstream change through a per-edge routing index to the downstream’s own partition owner, which applies it in place. See Re-key edges for the mechanism, and the next section for the rule that governs what such a listener should do with what it receives.

Read model: set. Event: fold.

What a @ProjectionListener should do depends on what it consumes: a read model carries current state, so a listener consuming one should set the field it copies (totals.totalBalance = summary.balance, as above); an event carries a delta, so a listener consuming one should fold it (totals.total += event.amount). publishCurrentState re-emits the upstream’s whole current value on every live apply, not the change since the last one — folding a read model re-adds that same current value again on every re-publish.

This matters most once a chain rolls many upstream rows into one downstream row — many accounts into one branch’s totals, say. To roll up, aggregate events, not read models: an event is a genuine delta, so re-keying it onto the rollup’s id and folding it there gives a correct running total; folding the upstream read model to aggregate it double-counts and is not consistency-guaranteed, however soundly the edge itself is delivered. See A read model is current state, an event is a delta for the full rule, a worked example, and how the two compose freely on the same downstream row.

Defaults differ by store

Whether publishCurrentState is enabled by default depends on the store type:

Annotation Default Notes

@JsonProjection

true

See JSON projections.

@JpaProjection

true

See JPA projections.

@InMemoryProjection

false

See In-memory projections. Must be set to true explicitly to participate in a chain.

In-memory projections default to false because they re-emit their full read-model set on every startup rebuild. If an in-memory upstream fed a persistent downstream, that downstream would be repeatedly re-fed on every node restart. Enable publishCurrentState = true on an in-memory projection only when it chains into another in-memory downstream (see Upstream/downstream store combinations below).

Upstream/downstream store combinations

Persistent upstream → in-memory downstream works correctly. When the downstream in-memory projection rebuilds on startup, the framework re-emits the upstream’s current read models into it, so the downstream starts fully populated without replaying the raw event log.

@JsonProjection(name = "account-summary", publishCurrentState = true) // persistent upstream
class AccountSummaryProjection { /* ... */ }

@InMemoryProjection(name = "hot-accounts", publishCurrentState = false) // in-memory downstream
class HotAccountsProjection {

    @ProjectionListener
    fun on(summary: AccountSummary, hot: HotAccounts) {
        if (summary.balance > BigDecimal("10000"))
            hot.accounts += summary.id
        else
            hot.accounts -= summary.id
    }
}

When an @InMemoryProjection cascades from a persistent, event-sourced @JsonProjection/@JpaProjection upstream (publishCurrentState = true), a rebuild of the in-memory projection blocks until that upstream has caught up to its high-water mark before re-emitting. This guarantees the downstream is reconstructed from a consistent, fully-caught-up snapshot rather than converging eventually from a not-yet-caught-up upstream. A read-model-fed persistent upstream (itself populated by another projection’s re-emission) has no live consumer chasing the high-water mark, so it is not HWM-gated.

If the upstream is stuck below its high-water mark (a halted consumer, a dead lease, no source), the rebuild logs a stall warning after spring.ddd.cqrs.projection.cascade-rebuild.upstream-hwm-stall-warn-interval (default 30s) and then HALTs the downstream projection after spring.ddd.cqrs.projection.cascade-rebuild.upstream-hwm-wait-timeout (default 120s), rather than retrying silently forever. A slow-but-progressing upstream keeps the rebuild waiting.

This ordering guarantee holds through each level only when the whole cascade is rebuilt together, since each intermediate re-emission target then blocks on its own direct upstreams in turn. Replaying only a leaf projection in isolation while a read-model-fed intermediate is still catching up live converges via that intermediate’s ongoing re-emission rather than a block.

In-memory upstream → persistent downstream is unsupported. An in-memory upstream re-emits its entire read-model set on every rebuild (every node restart), which would repeatedly re-feed the persistent downstream and corrupt or duplicate its data. Keep an in-memory upstream chained only into other in-memory downstreams.

Rebuild cascade

When an upstream projection rebuilds (its checkpoint is reset and the event log is replayed to reconstruct its read models), Spring DDD automatically cascades the rebuild to each downstream projection.

The cascade works as follows:

  1. The upstream completes its rebuild and its new generation is recorded.

  2. The framework re-emits the upstream’s current read models into each downstream (CascadeRebuildDriver calls reEmitUpstreamInto).

  3. Each downstream’s own publishCurrentState is suppressed during this re-emission, preventing further cascade propagation.

  4. The cascade is per-node, lease-gated (only one node drives a given downstream rebuild at a time), and generation-fenced (a stale re-emission from a previous generation is silently discarded).

  5. Orphaned rows in the downstream that no longer correspond to an upstream read model are pruned after the re-emission completes.

The cascade waits until all of a downstream’s upstream projections have settled before beginning re-emission. A multi-upstream downstream is not partially rebuilt.

Cycle detection

If projection A publishes its read model and projection B listens to it, and B also publishes its read model which A listens to, the two projections form a publishing cycle. Spring DDD detects this at startup and fails fast with a descriptive error that names every projection in the cycle.

Cyclic projection chain detected: AccountSummaryProjection → BranchTotalsProjection → AccountSummaryProjection.
Each projection in the cycle has publishCurrentState=true and listens to the read model of
its predecessor.

Break the cycle by setting publishCurrentState = false on at least one projection in it.

Ad-hoc projections in a chain

Chaining as described above is push-based: a publishCurrentState = true upstream re-emits its read model and the framework feeds each live downstream. A chain can also be consumed on demand with an ad-hoc projection — a pull that materializes a downstream read model when you ask for it and persists nothing.

Ad-hoc projection in a chain: on demand AdHocProjector.project() drains domain events and reads its direct upstream read models' current persistent state into a throwaway in-memory store
Figure 2. Ad-hoc materialization pulls a chain’s current state on demand

Within a chain, ad-hoc has two defining properties:

Downstream-only

An @AdHocProjection has no live consumer and never re-emits, so it cannot be a push upstream — a live projection that tried to consume its read-model type through the cascade would never be triggered (see #412). Persistent projections are the substrate that makes on-demand materialization possible; ad-hoc sits at the consuming end of a chain, not the producing end. A persistent projection that needs an ad-hoc result as an input builds it explicitly from inside its handler (Calling ad-hoc from inside a projection), rather than relying on the automatic cascade.

Current state from direct upstreams

A projection-fed ad-hoc target is materialized from its direct upstreams' current persistent rows — the same state the push cascade maintains, read on demand into a throwaway store. A point-in-time until in the past is supported for projection-fed targets whose entire upstream chain is event-sourced (each upstream is recursively replayed as-of that instant); a past until fails loud only when the closure has a live-only input.

See Ad-hoc projections for the full API, opt-in rules, and guarantees.

Error handling and halting

Chained projections follow exactly the same delivery, error-handling, and halt/resume rules as any other projection. A downstream that fails to apply an upstream read-model event will halt (or skip, depending on its onError setting) just like a projection that fails on a domain event.

See Delivery & error handling for the full onError reference, halt/resume/rebuild operations, and concurrency properties.