In-memory projections

@InMemoryProjection is the zero-config, no-database projection store included in the spring-ddd-cqrs core module. Read models live entirely in JVM heap and are rebuilt from the event log on every startup. No additional dependency, no database table, and no schema migration is required.

@InMemoryProjection(name = "account-summary") (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 name is required. It is the stable identity of the projection — it survives class renames and acts as the checkpoint key and (for GLOBAL-scope projections) the read-model key.

The annotation meta-annotates @Component, so no explicit @Bean declaration is needed.

Storage and the startup rebuild window

Read models are held in a ConcurrentHashMap inside the JVM. They are intentionally lost on shutdown: the event log is the durable source of truth, and the heap store is always reconstructed from it.

On startup, the framework replays every event in the event log from the beginning up to the current high-water mark and feeds them through the projection’s @ProjectionListener methods. During this catch-up window the read-model store is partially populated. Queries issued before the window closes may return stale or missing results, so it is worth designing the consuming code to tolerate a brief warm-up period or to wait until the projection has caught up (observable via the Actuator endpoint described in the Actuators section).

The length of the window is proportional to the size of the event log. Applications with a large event log may wish to consider a persistent store (@JsonProjection or @JpaProjection) if a startup rebuild window is not acceptable.

Event-source requirement

An @InMemoryProjection may only handle event types that are replayed from the event log (that is, events persisted by an event-sourced aggregate) or read-model types re-emitted by a registered upstream projection that has publishCurrentState enabled. The upstream may itself be in-memory (which must opt in with publishCurrentState = true) or a persistent @JsonProjection / @JpaProjection (which re-emit by default): on rebuild the framework re-emits the upstream’s current read models into the in-memory projection. Listening to a live-only event type — one that is neither event-sourced nor re-emitted by such an upstream — is rejected at startup, because such events are never replayed and their data would be permanently lost after a restart.

Multi-node behaviour

Each JVM node holds its own independent in-memory store; there is no shared, cross-node heap. Each node replays the event log independently on startup using its own JVM-local metadata (checkpoint, halt state, replay horizon): there is no distributed lease coordinating which node performs the in-memory rebuild. Every node converges to the same read-model state once its own catch-up completes.

This is different from persistent projection stores (@JsonProjection, @JpaProjection), which use a shared, lease-coordinated metadata store to ensure a single active consumer per projection across the cluster. For an in-memory projection, having each node rebuild independently is by design: the heap store is node-local by nature.

Bounded read-model count

By default the in-memory store is unbounded and will grow with the number of distinct aggregate ids seen in the event log. If the expected read-model count is large, set a per-projection upper bound to avoid heap exhaustion:

spring.ddd.cqrs.projection.in-memory.max-read-models=10000

The property accepts any integer >= 0; 0 (the default) means unbounded, and any positive value caps the read-model count. When the bound is reached and the framework would insert a new read model (updates to existing ids are always allowed), the projection halts (InMemoryProjectionLimitExceededException) rather than risking an out-of-memory condition. The bound is a best-effort safety valve, not a hard cap: because the count check and the insert are not a single atomic step, concurrent inserts may overshoot it by a small margin (on the order of the number of dispatch threads) before the limit trips. There is no eviction: the framework never removes read models to make room. If you need to store more read models than the heap can safely accommodate, switch to a persistent store.

@InMemoryProjection pins its error policy to HALT unconditionally and ignores spring.ddd.cqrs.projection.default-on-error. A transient store must never silently skip events: diverging silently across nodes would be undetectable. General delivery mechanics, halt/resume behaviour, and the onError policy for persistent stores are covered in Delivery & error handling.

publishCurrentState

@InMemoryProjection supports projection chaining: a downstream projection can depend on the read models emitted by an upstream in-memory projection. The publishCurrentState attribute controls whether the upstream re-emits its current read-model state during a cascade rebuild so the downstream can reconstruct itself:

@InMemoryProjection(
    name = "enriched-account-summary",
    publishCurrentState = true (1)
)
class EnrichedAccountSummaryProjection { ... }
1 Defaults to false. Enable this only when the upstream is chained into another in-memory projection. Enabling it for a chain that includes a persistent downstream projection would cause the upstream to re-emit its read models on every rebuild, repeatedly feeding the persistent store. For the full chaining reference — including mixed-store chains and cascade rebuild ordering — see Chaining projections.

Schema evolution

Because read models are rebuilt from scratch on every startup, in-memory projections support a JSON-blob-style evolution model — no DDL migration and no data backfill is ever required.

Adding a field

Add the property with a Kotlin default value. The event-log replay constructs fresh read models using the updated class; every instance will carry the new field after the next startup.

Removing a field

Drop the property. The event-log replay simply never populates the absent field; no stored state references it.

Structural changes (rename, type change)

The projection rebuilds from scratch on every startup anyway, so any structural change takes effect automatically after a restart. No explicit rebuild trigger is needed for in-memory projections.