Scaling projections
A projection has two independent levers for scaling its apply work, and they solve different problems:
-
Apply concurrency — how many of this projection’s event keys one instance applies in parallel. Always available, needs no opt-in, capped by the
concurrencyattribute on the projection annotation. -
Partitioning — whether this projection’s work is spread across instances instead of pinned to one. An explicit per-projection opt-in via
@Partitioned.
Neither implies the other. A single-instance deployment can still raise its apply concurrency; a partitioned projection running on three instances still obeys its own per-instance concurrency cap on each of them. This page covers both, how they combine, and how to configure them per environment.
Apply concurrency (within one instance)
Each instance applies all of its projections' read-model updates through one shared worker pool. Work is
routed by the read model’s key, so a given row is always handled by the same worker — one update at a time
per key, many keys in parallel. The concurrency attribute on the projection annotation caps how many of
this projection’s keys that instance applies at once: at most that many in parallel, still serial per
key. The cap applies while a projection catches up or drains its buckets — the paths that move the most
events; live updates (see Delivery to a partitioned projection) are applied as they arrive and
are not capped, so a sudden burst can briefly exceed it.
@JsonProjection(name = "account-summary", concurrency = 4) (1)
class AccountSummaryProjection {
@InitReadModel
fun init(id: AccountId) = AccountSummary(id = id, balance = BigDecimal.ZERO)
@ProjectionListener
fun on(event: AccountOpened, summary: AccountSummary) {
summary.balance = event.initialBalance.value
}
@ProjectionListener
fun on(event: MoneyDeposited, summary: AccountSummary) {
summary.balance += event.amount.value
}
}
| 1 | concurrency bounds how many of this projection’s event keys this instance applies in parallel —
serial per key, concurrent across keys. 0 (the default, and the value when the attribute is
omitted) means uncapped: the projection simply draws from the shared pool at whatever width the pool
grants it. A positive value narrows that further, to at most N for this projection specifically —
useful when one projection’s apply work is expensive enough that it should not crowd out every other
projection sharing the pool. |
The cap can only remove parallelism, never add it: the shared pool’s own width
(spring.ddd.cqrs.projection.executor.concurrency-limit) is the hard ceiling for every projection on the
instance, so setting concurrency higher than that width changes nothing — a projection can never apply
more keys at once than the pool has workers. See Combining the two below for how this interacts with
@Partitioned.
concurrency says nothing about how many instances run this projection — that is the other, independent
lever, described next.
Partitioning (across instances)
Running on multiple instances describes the default contract:
one instance owns a projection at a time, and N instances give you failover, not parallelism.
@Partitioned changes that contract for a single, chosen projection: its catch-up work is split into
buckets by event key across every live instance, so it scales out with the number of instances instead of
running on one.
This is a per-projection opt-in, not a global switch — most projections stay single-owner, and only the
ones that need the extra throughput carry the annotation. @Partitioned itself takes no attributes: it is
a bare marker meaning "distribute this projection’s buckets across instances." How wide this instance
applies its share of the work is still governed by the projection annotation’s concurrency attribute,
described above — the two are set independently.
Declaring a partitioned projection
@Partitioned (1)
@JsonProjection(name = "account-summary", concurrency = 4) (2)
class AccountSummaryProjection {
@InitReadModel
fun init(id: AccountId) = AccountSummary(id = id, balance = BigDecimal.ZERO)
@ProjectionListener
fun on(event: AccountOpened, summary: AccountSummary) {
summary.balance = event.initialBalance.value
}
@ProjectionListener
fun on(event: MoneyDeposited, summary: AccountSummary) { (3)
summary.balance += event.amount.value
}
}
| 1 | Bare marker — opts this projection into cross-instance bucket distribution. There is nothing to configure on it. |
| 2 | concurrency still means exactly what it means without @Partitioned: the per-instance apply cap
from Apply concurrency (within one instance). Setting it here bounds how many event keys this
instance’s share of the buckets applies in parallel. |
| 3 | Ordinary @ProjectionListener methods, unchanged. Partitioning is a consumption-side concern; how you
declare listeners does not change at all. |
A partitioned projection may also react to something delivered only live — a read model re-published by another projection, say — never persisted to the event store the bucket poll reads. See Delivery to a partitioned projection below for how those are handled.
How the work is split
Every event carries an event key — the value it is routed by. At write time a partition_key is stamped
on it from a hash of that key into a fixed number of buckets (spring.ddd.partitioning.partitions, default
256 — see Configuration properties). For an ordinary domain
event the key is its aggregate id — the field you mark with @AggregateId.
Because the hash is a pure function of the key, every event with the same key always lands in the same
bucket. So an aggregate’s whole event stream stays together in one bucket — which is what makes
bucket-level parallelism safe: an aggregate’s events are never split across two instances, so per-aggregate
order is preserved no matter which instance owns the bucket at the time.
Each application instance runs a lightweight coordinator that:
-
for a consumer sharded across buckets, heartbeats its liveness into a shared membership table and claims a disjoint, load-balanced subset of buckets across the live member set (rendezvous hashing — minimal movement when an instance joins or leaves), under a fenced ownership lease — a consumer that declares a single partition (not merely one that happens to own a single bucket) instead claims it directly on the rebalance cadence, and writes no membership row at all, and
-
drains its owned buckets in ascending position order, advancing a per-bucket checkpoint as it goes.
This all happens automatically once the annotation is present — there is nothing to wire up. Adding or removing instances triggers a rebalance on the next pass; only the buckets that actually changed owner move, everything else keeps draining undisturbed.
|
On a single instance
That one instance owns every bucket and drains them on its single poller thread, in sequence — so
|
Safety rules
@Partitioned is guarded at startup, not at runtime — a projection that cannot be safely split fails loud
with an actionable message instead of silently corrupting or duplicating data.
- Aggregate-keyed or re-keyed read models
-
A read model whose id is the aggregate id runs in the default, cheapest native mode — events stay in their stamped bucket. A read model fed by more than one aggregate (a
@ReadModelKey, or an@Id/@EmbeddedIdthat is not the aggregate id) runs in index mode (see below), which re-keys its events onto the read-model id for total order per row. AGLOBAL-scoped projection has no per-row key to partition on at all, so@Partitionedis ignored — it runs single-owner, with a startupWARN(the annotation has no effect there and can be removed), rather than failing. A projection-fed (chained) target is partitionable in two shapes: co-keyed with its upstream, read directly (Feed mode below), or re-keyed through a@ReadModelKeyonto a different id, routed through a per-edge index to its own partition owner (Re-key edges below). - An optimistic-lock guarded read model
-
@JsonProjectionread models qualify automatically. A@JpaProjectionread model must carry ajakarta.persistence.@Versionfield — see Domain events & optimistic locking for the same requirement on the write side. Without version-guarded writes, concurrent instances (or a rolling deploy that briefly runs both an old and a new instance) can silently lose an update. This precondition is only enforced when the projection actually scales out (partitioned.enabledeffectivelytrue); a single-owner projection — including one carrying@Partitionedwithenabled: false— has the same live-write exposure@Versionprotects against whenever it mixes event-store and live triggers, so a@JpaProjectionstill wants@Versionregardless of grain, even though the framework does not require it there.
|
A persistent projection is single-owner (one instance drains it at a time, fenced by a lease) precisely
because its read model is shared: a second writer would duplicate work and race. An in-memory read model
is shared by nothing, so the opposite rule applies — single-ownership would leave every non-owner instance
blind to the read model. That is why The projection’s |
- A single event-store ordering
-
Partitioned coordination cursors are positions in one event-store ordering, so an application that exposes more than one
DataSourcebean fails loud at startup as soon as any@Partitionedprojection is registered — even if every@Partitionedprojection actually reads from the same single event store. This is a known limitation, not a deliberate restriction: a read replica, anAbstractRoutingDataSourcewith its routed targets, or a migration-only datasource are all common, legitimate reasons a real application has more than oneDataSourcebean, and none of them imply more than one event-store ordering. Multi-event-store partition coordination is a planned future improvement.
Index mode (cross-aggregate read models)
When a partitioned read model is fed by more than one aggregate — for example an AccountStatement row
built from MoneyDeposited (keyed on accountId) and CustomerRenamed (keyed on customerId) — the two
event families hash to different buckets on different instances. The framework runs such a projection in
index mode: a single-owner indexer tails the event log and records, for each event, which read-model
row(s) it updates, co-locating a row’s events in one bucket applied in total order.
What index mode buys you is scale-out for a cross-aggregate read model — the same horizontal scaling a native aggregate-keyed partitioned projection gets. The expensive per-row work — running the listeners and writing the read model — is sharded across buckets and applied in parallel by owners on different instances, instead of being pinned to a single owner. Only the cheap indexing step (key evaluation + a pointer write, no payload copy) is serial, so the part that actually bottlenecks a busy projection is the part that parallelizes — and you get this with total order per row and exactly-once apply preserved. Reach for it (it is automatic) when a cross-aggregate read model’s apply throughput on a single owner is the bottleneck.
Index mode is derived automatically from the read model’s key; there is no flag. The scale-out has a cost, paid only by index-mode projections (native aggregate-keyed projections pay none of it):
-
A single-owner indexer per projection — total order requires one funnel. The per-event work is cheap (key evaluation + a pointer write, no payload copy), and different projections' indexers spread across instances, so it is not a global chokepoint.
-
Events are read twice — once by the indexer to route them, once by the owner to apply them (the index stores pointers, not payloads).
-
One extra poll hop of latency — an event flows commit → indexer → owner.
Disabling partitioning always runs the same projection correctly on a single owner in global order — the simplest choice, and the right one when a cross-aggregate read model is low-volume enough that scale-out is not needed.
Feed mode (partitioned projection-fed cascades)
A projection fed only by an upstream read model — see Projection
chaining — has no event-store trigger of its own to partition by. It can still carry @Partitioned, but
only in one narrow, provable shape: the downstream must be co-keyed with its upstream, meaning a change
to an upstream row always re-derives the downstream row of the same id, so the two land in the same
bucket by construction.
@JsonProjection(name = "account-summary", publishCurrentState = true)
class AccountSummaryProjection {
@InitReadModel
fun init(id: AccountId) = AccountSummary(id = id, balance = BigDecimal.ZERO)
// ...
}
@Partitioned (1)
@JsonProjection(name = "account-risk")
class AccountRiskProjection {
@ProjectionListener
fun on(summary: AccountSummary, risk: AccountRisk) { (2)
risk.score = scoreOf(summary.balance)
}
}
| 1 | AccountRiskProjection has no domain-event listener of its own — every trigger is the upstream
AccountSummary read model. This is only partitionable because it is co-keyed: AccountRisk’s id is
resolved from `AccountSummary’s own `@AggregateId (accountId), with no @ReadModelKey in between. |
| 2 | An ordinary @ProjectionListener, unchanged — feed mode is a consumption-side concern, exactly like
native and index mode. |
This is feed mode, the third partitioning mode alongside native and index. Instead of tailing the event store, the downstream tails a durable, partitioned feed of the upstream’s own read-model changes and re-derives its row from the upstream’s current state — cross-instance, without requiring the two projections' buckets to be owned by the same node.
A projection-fed target that is not co-keyed — one that resolves its id through a @ReadModelKey onto
the upstream, rather than reading the upstream’s own @AggregateId directly — is admitted too, just not
through this direct drain: the framework cannot prove from metadata alone that such a mapping lands in the
same bucket as its upstream, so reading the upstream’s feed for this bucket the way a co-keyed edge does
would risk a cross-node double-write. Instead it is routed through a per-edge index — see
Re-key edges below.
|
|
Re-key edges (routed read-model consumption)
A @Partitioned projection may also consume an upstream read model through a re-defining @ReadModelKey
— one whose downstream id does not resolve to the upstream row’s own id, so the two land in different
buckets, owned by different instances. This is a re-key edge: a cascade source, not a partition mode of
its own — it composes on top of whichever primary mode the downstream already runs in (native, index, or
feed), adding one more routed input alongside its event triggers and any co-keyed feed edges.
Because the affecting upstream row can live in any bucket, a re-key edge cannot be drained directly the way a co-keyed one is (Feed mode above). Instead, a stateless single-owner indexer — one per edge — reads the upstream’s whole read-model feed, re-keys each change onto the downstream id, and appends a routing pointer to the downstream’s own partition. The downstream’s own owner then applies that pointer in place, exactly once, in the same bucket its native events already use — so the row stays correctly bucketed and its feed stays coherent no matter how the upstream and downstream ids relate.
@JsonProjection(name = "account-summary", publishCurrentState = true)
class AccountSummaryProjection {
@InitReadModel
fun init(id: AccountId) = AccountSummary(id = id, branchId = ..., balance = BigDecimal.ZERO)
// ...
}
@Partitioned (1)
@JsonProjection(name = "branch-totals")
@ReadModelKey(type = AccountSummary::class, value = "branchId") (2)
class BranchTotalsProjection {
@InitReadModel
fun init(id: BranchId) = BranchTotals(id = id, totalBalance = BigDecimal.ZERO)
@ProjectionListener
fun on(summary: AccountSummary, totals: BranchTotals) { (3)
// see the next section for what this listener should — and should not — do
}
}
| 1 | Still a bare marker — a re-key edge composes with @Partitioned the same as any other source. |
| 2 | BranchTotals’s id resolves from `AccountSummary.branchId, not `AccountSummary’s own id — the two
hash to different buckets. That is exactly what is now admitted and routed through the per-edge index,
rather than rejected at startup. |
| 3 | An ordinary @ProjectionListener, unchanged — re-key is a consumption-side concern like every other
mode. |
A read model is current state, an event is a delta
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; an event carries a delta, so a listener consuming one should fold it —
totals.total += event.amount. Getting this backwards on a read model is a real mistake, not a style
choice: publishCurrentState re-publishes the upstream’s whole current value on every live apply, not
the change since the last one, so folding it re-adds the same current value again each time it is
re-published. When the re-keyed relationship is genuinely 1:1 — an upstream row that always resolves to one
unique downstream row — setting is fully correct, however many times the same value is re-delivered.
branchId fans many accounts into one BranchTotals row, and that shape is exactly what tempts a listener
into the mistake:
@ProjectionListener
fun on(summary: AccountSummary, totals: BranchTotals) {
totals.totalBalance += summary.balance // WRONG — re-adds the same current balance on every re-publish
}
The edge above is still delivered soundly — exactly once per observed change, routed to the correct row —
but that does not make the aggregation correct; the framework logs a startup WARN naming the projection
and the edge. To roll up, aggregate events, not read models: MoneyDeposited/MoneyWithdrawn are
genuine deltas, so re-keying the event onto branchId and folding it there is the correct way to build a
running total across many accounts — see Index mode (cross-aggregate read models) above, which already
supports exactly this fan-in.
Composition is free. A downstream is not limited to one kind of input: the same BranchTotals row can
simultaneously fold a re-keyed event (a correct running total), set a field from a co-keyed read model, and
set another field from a re-key read-model edge like BranchTotalsProjection above — every contribution
lands on that row, applied by the row’s single owner, whichever source it came from.
Rebuilding a partitioned re-key downstream
A re-key edge’s routing index is stateless: it resolves and routes a row’s current key, but it never
proactively re-signals a row that a key change vacated. If branchId moves — an account transfers to a
new branch, say — the new branch’s BranchTotals row is corrected on the very next routed change, but the
old branch’s row is left holding stale numbers until the projection is rebuilt. The same is true after any
declaration change: an edited @ReadModelKey, a listener added or removed, or a change to the read
model’s shape or computation. None of these self-heal on the in-place path — nothing re-routes to a vacated
row until the projection is rebuilt.
- How (a non-partitioned projection)
-
Trigger the ordinary rebuild described in Delivery & error handling —
POST /actuator/projections/{name}/replay, orprojectionManager.replay(BranchTotalsProjection::class). - How (a partitioned re-key downstream)
-
The same as any other projection —
POST /actuator/projections/{name}/replay, orprojectionManager.replay(BranchTotalsProjection::class). The rebuild runs in place, distributed across the bucket owners: the projection is quiesced for at least one lease duration, its partition cursors, routing index and dedup markers are then cleared as one epoch, and every owner re-drains its own buckets from the beginning under a new generation. Read models the rebuild does not re-create are removed when it completes. - When
-
After a declaration change (an edited
@ReadModelKey, a listener added or removed, or a change to the read model’s shape or computation) — or after a runtime re-key value move whose vacated old group needs correcting immediately, rather than waiting for a rebuild that would happen anyway for other reasons.
What an in-place partitioned rebuild resets
replay on a @Partitioned projection is not a single node’s job — the bucket owners across the cluster
each re-drain their own buckets. One epoch, cleared together, covers everything the projection owns: its
partition cursors, its change-feed counter and published frontier, its re-key routing indexes, and its
cascade dedup markers. Nothing from the old epoch survives into the new one.
Before any of that happens the projection is quiesced — every bucket owner stops draining at its next
pass — so no in-flight batch from before the reset can still be applying once the cursors and indexes are
cleared. The quiesce is not instantaneous and is not meant to be: the projection stays paused for at least
one lease duration (spring.ddd.cqrs.projection.lease-duration, one minute by default) before its epoch is
reset, which is what guarantees that every owner — including one whose node has since gone away — has
observed the pause and stopped. The pause lifts on its own once the reset has run; there is nothing to resume
manually.
When a rebuild spans a cascade, the closure is released upstream first: a downstream stays paused until every upstream it consumes has had its own epoch reset. Otherwise the downstream would drain the upstream’s pre-reset change feed, whose sequence numbers the upstream is about to re-mint from the beginning, and the downstream’s cursor would sit permanently beyond everything the new epoch ever publishes. Budget for that ordering: the total wait before the last projection in a cascade is reset is roughly the depth of the closure times one lease duration plus one poll interval, since each level is only released after the level above it has been reset and that release is observed on the next poll.
The very first time a partitioned projection is driven it runs this same cycle by itself, without anyone
calling replay: it records a horizon, pauses, resets its epoch and re-drains its history once. Read models
are only ever removed at the end of a cycle that actually reset the epoch that produced them, so this
one-off pass is what makes the completion prune safe rather than destructive. Expect one quiesce of at least
a lease duration and one full re-drain the first time a projection is seen; afterwards the durable progress
row exists and the projection catches up incrementally.
Side effects still matter during a rebuild. An @IgnoreOnReplay listener is suppressed for the
event-sourced portion of the catch-up, exactly as it is on a non-partitioned rebuild — but a delivery fed
from an upstream read model (feed mode or a re-key edge) is not part of that catch-up and is not
suppressed, since it is not replaying stored history, it is reacting to the upstream’s current state.
Not every partitioned projection can be rebuilt this way. One with no pollable source — reached only
through the live-only path, with no event-store trigger, no co-keyed feed, and no re-key edge — has nothing
to re-drain: a rebuild would clear its read models with nothing to repopulate them from. replay rejects
that projection with a reason instead of accepting it.
replay likewise rejects a partitioned projection you have deliberately halted. Pausing it for a rebuild
would overwrite that stop and then lift it as part of the epoch reset, silently undoing the decision to stop
it; resume it first, then trigger the rebuild. An error halt is different — a rebuild is a legitimate remedy
for a read model an event poisoned, so it is accepted and supersedes the error halt. In the other direction,
an error raised by a batch that was already in flight when the rebuild paused the projection does not
displace the pause: absorbing exactly those batches is what the pause is for, and the rebuild proceeds.
Delivery to a partitioned projection
Nothing is dropped — every event still reaches the projection. What changes is which path delivers it.
The projection’s ordinary events — the ones stored in the event store — arrive through the bucket poll, in order, exactly as they do for any event-sourced projection. They are not applied from the after-commit signal that fires the instant a command runs: that signal fires on whichever instance ran the command, which is usually not the bucket’s owner, so applying it there would sidestep the owner’s ordering and its ownership fence. Delivering these events through the owner’s poll keeps each aggregate’s events in order. The cost is freshness — updates appear at poll cadence rather than instantly, the same throughput-for-latency bargain a Kafka consumer group makes when it spreads a topic across consumers.
A projection can also react to something that lives only in memory and is never written to the event store
— for example a read model re-published by another projection. The bucket poll cannot see it, so it is
delivered live (no opt-in needed; the framework logs it at INFO on startup, naming it and this
implication). It has no fixed ordering against the stored events, and if a live apply and a poll
apply ever touch the same row at once, the write-conflict retry (below) reconciles them. That is fine for
an occasional side channel, but not something to lean on for a projection’s main event flow.
|
The write-conflict retry is general, not specific to live-only delivery
Every projection apply — live or catch-up, partitioned or not — runs under the same bounded write-conflict
retry. It retries the whole On exhaustion of that budget the failure surfaces to the projection’s configured error policy — the same
policy for a single-owner and a partitioned projection. The projections actuator reports a partitioned projection’s halt state, but not yet its per-partition lag — its reported checkpoint still reflects the single-owner cursor, so read the halt state rather than the lag figure for a partitioned projection until that reporting is refined. |
Combining the two
Apply concurrency and partitioning compose along two different axes: partitioning spreads a projection’s
buckets across instances (horizontal scale-out), while concurrency sets how many event keys
one instance applies in parallel out of its own share (serial per key). Raising either one only helps up
to the ceiling the other imposes:
-
Adding instances without partitioning does nothing for this projection — a single-owner projection stays pinned to one instance regardless of how many others are running.
-
Adding
@Partitionedwithout raisingconcurrencystill scales out, but each instance applies its bucket share at whatever width its ownconcurrency(or the shared pool, if uncapped) allows. -
A partitioned
AccountSummaryProjection(concurrency = 4)running on three instances applies up to4 × 3 = 12event keys in parallel across the cluster — bounded, on each instance, byspring.ddd.cqrs.projection.executor.concurrency-limit, and overall by the bucket count (spring.ddd.partitioning.partitions).
Per-environment configuration
@Partitioned is the opt-in for cross-instance scale-out, and the projection annotation’s concurrency
attribute is the opt-in for a per-instance apply cap — each is independently overridable per environment,
keyed by the projection’s storage name (the name passed to @JsonProjection/@JpaProjection):
spring:
ddd:
cqrs:
projection:
executor:
concurrency-limit: 8
queue-capacity: 1024
overrides:
account-summary:
partitioned:
enabled: true
concurrency: 4
| Property | Type | Notes |
|---|---|---|
|
Boolean |
Overrides whether partitioned (cross-instance) consumption is active for this projection. Does not
enable partitioning on its own — it only tunes a projection that already carries |
|
Int |
Overrides the projection annotation’s |
Every field defers to the projection’s own annotation when left unset, so an override only needs to state
what actually differs in that environment. Setting concurrency: 0 is itself a deliberate statement —
"uncapped here" — distinct from leaving the field unset.
|
The overall concurrency budget
A projection’s There is no separate "number of projections" limit — they all draw from this one pool. The three levers compound:
|
Changing partitioning mode — stop/start cutover
Toggling @Partitioned (or its partitioned.enabled override) is safe under a rolling deploy — the
safety rules above apply regardless of which instances have picked up the change — but a window with some
instances single-owner and others partitioned does duplicate work and adds optimistic-lock retry churn
while it lasts. Prefer a stop/start cutover when changing a projection’s partitioning mode: stop the
application, deploy the change, start it back up.
|
Scaling a projection back down from multiple buckets to a single owner (setting |
|
What
enabled: false actually runs on, and why turning it back on needs a cutoverA projection whose triggers come exclusively from the event store, annotated
Turning |
Steady-state scale-out — adding or removing instances without changing the mode — needs no cutover at
all; that is the coordinator’s normal rebalance, described above. Changing a projection’s concurrency
attribute (or its override) needs no cutover either — it only governs local apply width and takes effect
the next time that instance starts.