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 concurrency attribute 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 @Partitioned gives you no horizontal scale-out there. The projection’s concurrency attribute still applies: up to that many event keys apply simultaneously within each bucket batch. But a non-partitioned projection already applies its event keys concurrently through the shared projection executor, so on a single instance the practical addition is being pre-wired to scale out with no further changes the moment you add a second instance. The scale-out itself only materializes with more than one instance.

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/@EmbeddedId that 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. A GLOBAL-scoped projection has no per-row key to partition on at all, so @Partitioned is ignored — it runs single-owner, with a startup WARN (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 @ReadModelKey onto a different id, routed through a per-edge index to its own partition owner (Re-key edges below).

An optimistic-lock guarded read model

@JsonProjection read models qualify automatically. A @JpaProjection read model must carry a jakarta.persistence.@Version field — 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.enabled effectively true); a single-owner projection — including one carrying @Partitioned with enabled: false — has the same live-write exposure @Version protects against whenever it mixes event-store and live triggers, so a @JpaProjection still wants @Version regardless of grain, even though the framework does not require it there.

@InMemoryProjection is never partitioned, and its scaling model is the inverse of a persistent projection’s. Its read model lives in the JVM heap, so every instance must hold its own copy: each instance runs the projection independently — rebuilding it from position 0 of the event log on every startup and applying every subsequent event — with no cross-instance lease and no single-owner handoff.

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 @Partitioned on an @InMemoryProjection is a no-op: the framework logs a startup WARN and ignores it rather than failing — there is no shared store to distribute across instances, and each instance already builds the whole thing.

The projection’s concurrency attribute still applies on each instance, capping how many read-model rows that instance rebuilds in parallel during catch-up.

A single event-store ordering

Partitioned coordination cursors are positions in one event-store ordering, so an application that exposes more than one DataSource bean fails loud at startup as soon as any @Partitioned projection is registered — even if every @Partitioned projection actually reads from the same single event store. This is a known limitation, not a deliberate restriction: a read replica, an AbstractRoutingDataSource with its routed targets, or a migration-only datasource are all common, legitimate reasons a real application has more than one DataSource bean, 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.

@Partitioned on a projection with no durable, pollable source at all — no event-store trigger, no co-keyed upstream feed, and no re-key edge, only ever reached through the live-only path — has nothing to shard. It still registers and runs correctly, but single-owner (N=1), and the framework logs a startup WARN naming the projection: the annotation has no effect there and can be removed.

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 handlingPOST /actuator/projections/{name}/replay, or projectionManager.replay(BranchTotalsProjection::class).

How (a partitioned re-key downstream)

The same as any other projection — POST /actuator/projections/{name}/replay, or projectionManager.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 ConcurrencyFailureException family: optimistic-lock conflicts and transient database deadlocks / lock-acquisition failures. Only the cause differs — a live-vs-poll collision surfaces as an optimistic-lock conflict; an ordinary concurrent apply may surface as a database deadlock. The budget is spring.ddd.cqrs.projection.optimistic-lock.retry.max-attempts (default 3) and spring.ddd.cqrs.projection.optimistic-lock.retry.delay (default 50ms).

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. HALT records a durable halt (carrying a HaltReason, surfaced by the projections actuator) and stops the projection; for a partitioned projection every bucket owner, on every instance, observes that one durable flag, so all buckets stop — not only the one that hit the poison. An operator resumes after a fix, and an ERROR halt auto-resumes on the next restart; a poison event in the single-owner re-partition indexer of an index-mode projection halts it the same way. SKIP logs the poison, advances past it, and keeps consuming. Data stays safe either way — the checkpoint holds and re-apply is idempotent, so nothing is silently dropped.

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 @Partitioned without raising concurrency still scales out, but each instance applies its bucket share at whatever width its own concurrency (or the shared pool, if uncapped) allows.

  • A partitioned AccountSummaryProjection(concurrency = 4) running on three instances applies up to 4 × 3 = 12 event keys in parallel across the cluster — bounded, on each instance, by spring.ddd.cqrs.projection.executor.concurrency-limit, and overall by the bucket count (spring.ddd.partitioning.partitions).

Scaling projections: partitioning spreads a projection’s buckets across instances (horizontal scale-out) while concurrency sets how many event keys one instance applies in parallel (serial per key) — two independent levers

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

overrides.<name>.partitioned.enabled

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 @Partitioned (an override for a projection without the annotation is ignored). Unset defers to the annotation (enabled whenever @Partitioned is present). Use enabled: false to run an annotated projection single-owner in a given environment — for example a single-instance dev box.

overrides.<name>.concurrency

Int

Overrides the projection annotation’s concurrency attribute — the per-instance apply cap. Unset defers to the annotation. 0 explicitly relaxes this projection to uncapped for this environment, even if the annotation itself sets a cap. Negative values are rejected. Independent of partitioned.enabled: it applies whether or not this projection is partitioned.

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 concurrency sits on top of one shared apply executor per instance. Every projection — partitioned or not, live or catching up — hands its ready read-model updates to the same striped worker pool, routed by read-model id so a given row is always applied by the same worker: sequential per row, parallel across rows. That pool’s width is the instance-wide ceiling on concurrent apply work across all your projections, set by spring.ddd.cqrs.projection.executor.concurrency-limit (default: the number of available processors — see Configuration properties).

There is no separate "number of projections" limit — they all draw from this one pool. The three levers compound:

  • Per projection, per instance — the projection annotation’s concurrency attribute (or overrides.<name>.concurrency above) is an admission limit for one projection on the shared pool. Setting it above the pool’s width only buys queueing slack, not more parallel apply.

  • All projections on one instancespring.ddd.cqrs.projection.executor.concurrency-limit sizes the shared pool itself. Raise it to give the instance more apply throughput overall. Polling is cheap and runs on a small, separate scheduler; the shared pool is where the apply work — and the concurrency that matters — lives.

  • Across instances — run more of them, with the projection carrying @Partitioned. Partitioning shards a projection’s buckets across the live set, so its total parallelism is roughly the per-instance concurrency × the number of instances, bounded by the bucket count (spring.ddd.partitioning.partitions).

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 partitioned.enabled: false on a projection that has actually been running with more than one bucket) is not yet a supported transition: the framework detects the leftover per-bucket state at startup and refuses to admit the projection rather than risk silently skipping or re-applying events. If you hit this, keep partitioned.enabled: true for that projection until grain-narrowing is supported. This restriction does not apply to a projection that has never scaled out — annotating it @Partitioned with enabled: false from the start works today. It also only applies to a projection whose triggers come exclusively from the event store. One that consumes an upstream read model — an AccountRisk chained onto AccountSummary, say, whether or not it also has event-store triggers of its own — simply returns to the single-owner path when its override is turned off, so scaling that one back down works today. If it still has event-store triggers, its first single-owner pass folds the per-bucket cursors back into its checkpoint; if it is fed only by the upstream read model, it has no such pass and its now-unread per-bucket rows are left behind.

What enabled: false actually runs on, and why turning it back on needs a cutover

A projection whose triggers come exclusively from the event store, annotated @Partitioned with partitioned.enabled: false, still runs on the partitioned runtime — at a single bucket, draining the whole log in log order, which is the single-owner consumption the flag asks for. A few operational details move with it:

  • The actuator reports the projection as partitioned, with one bucket.

  • Durable progress lives in the per-bucket partition ownership cursors, not in PROJECTION_METADATA.replay_checkpoint_position, which stops advancing. External monitoring watching that column will read a false "stalled" signal — take lag from the actuator instead.

  • A rebuild runs the partitioned quiesce/epoch-reset protocol rather than the plain one.

  • While a rebuild is quiescing the projection, resume() refuses to clear the REBUILD pause. That pause belongs to the rebuild, which releases it once the epoch reset completes — wait for it rather than resuming by hand.

Turning partitioned.enabled back to true afterwards is not a safe live toggle. Because the metadata replay checkpoint stopped advancing while the projection ran at a single bucket, every bucket seeded on the way back up starts from that frozen position and re-applies everything the projection has consumed since — double-counted aggregations and re-fired non-idempotent side effects. Re-enable it as a stop/start cutover with a fresh rebuild of that projection, never as a flag flip on a running system.

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.