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.
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 |
|---|---|---|
|
|
See JSON projections. |
|
|
See JPA projections. |
|
|
See In-memory projections.
Must be set to |
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 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 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:
-
The upstream completes its rebuild and its new generation is recorded.
-
The framework re-emits the upstream’s current read models into each downstream (
CascadeRebuildDrivercallsreEmitUpstreamInto). -
Each downstream’s own
publishCurrentStateis suppressed during this re-emission, preventing further cascade propagation. -
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).
-
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.
Within a chain, ad-hoc has two defining properties:
- Downstream-only
-
An
@AdHocProjectionhas 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
untilin 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 pastuntilfails 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.