Running on multiple instances

Spring DDD is safe to run on multiple application instances against a single shared database. Doing so gives you failover and availability — not horizontal scale-out of an individual projection or saga. This page states the contract you operate under.

Single owner per projection and per saga type

Each projection and each saga type is processed by one instance at a time, protected by a durable lease. If that instance dies, another acquires the lease and continues from the last checkpoint. Running N instances therefore gives you failover, not parallelism: a single hot projection or saga does not spread its work across instances by default. A projection that needs real horizontal scale-out can opt into @Partitioned consumption instead — see Scaling projections.

Triggering a rebuild (replay) on a @Partitioned projection is itself a cluster-wide operation, not a single instance’s job: every bucket owner stops draining, the projection stays quiesced for at least one lease duration (spring.ddd.cqrs.projection.lease-duration, one minute by default) so that every owner has certainly observed the pause, its cursors, routing indexes and dedup markers are then cleared as one epoch, and each owner re-drains its own buckets from the beginning. Across a cascade the wait compounds: a downstream is released only after its upstreams have been reset, so budget roughly the depth of the closure times one lease duration plus one poll interval. You do not need to pick an instance to run it from, and you do not need to disable partitioning first — see What an in-place partitioned rebuild resets.

Side effects are at-least-once — make them idempotent

The framework guarantees your consumer state is written once under the lease. It does not extend that guarantee to side effects. A handoff between instances, or a crash in a narrow window, can re-deliver:

  • a command emitted from a saga, and

  • a side-effect projection or saga handler (one that does more than write its read model).

Both must be idempotent — applying the effect twice must be indistinguishable from applying it once. In a multi-instance deployment this is a hard requirement, not a best-effort suggestion.

upsert-style writes keyed on a stable id are idempotent by construction. For non-idempotent effects (charging a card, sending an email), guard them with a deduplication key derived from the triggering event or command id.

Emit saga side effects as commands, or make direct I/O idempotent

The framework can only recover the effects whose contract it owns — commands (deferred until after the saga’s state commit) and deadlines. Both are fire-and-forget, replayed through the framework’s own machinery, and idempotent by contract.

An arbitrary collaborator a handler calls directly — a WebClient, a message producer — is not something the framework can defer or recover: it may return a value the handler consumes, and the framework has no replay story for it. A raw external call inside a saga handler therefore fires un-deferred and un-recovered.

The idiomatic path for external I/O is to send a command whose handler performs the work in its own transactional, retryable, idempotent unit. Keep a saga a state machine, not an I/O site. Direct I/O in a handler is yours to make idempotent.

Do not write to the read model out-of-band while consumers run

The framework’s optimistic-concurrency protection covers framework writes to the read-model / projection store, not arbitrary external ones. An out-of-band write from outside the framework can be clobbered by an in-flight rebuild. If you must touch read-model tables directly, do it while the consumers are stopped.

There is nothing to tune on the read frontier

The read frontier — how far catch-up is allowed to advance — is derived from the database’s own transaction visibility. There is no timeout to set, and it never drops data. You do not configure it, and there is no knob that trades safety for latency.

A long-running transaction can delay projections

Because the frontier respects transaction visibility across the whole database, an unrelated long-running transaction — a backup, a batch job, a stuck session — temporarily holds the frontier back. Projections lag while it runs and catch up once it ends; no data is lost. This can present as "the projection is stuck" with no obvious cause, so the framework exposes a blocked-duration gauge, cqrs.projection.hwm.blocked.seconds (see Actuators). Watch it if projections appear to stall: a rising blocked duration points at a long transaction somewhere in the database, not at the framework.

SQL Server behaves slightly differently while an append is in flight. Under its default lock-based READ COMMITTED isolation, a projection poller briefly waits on the row lock of an uncommitted append rather than reading past it. This is data-safe and bounded by the (short) append transaction, but it is a per-append wait specific to SQL Server — distinct from the database-wide stall a long transaction causes on any engine. On PostgreSQL, MVCC lets the poller read past an in-flight append instead of waiting.