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.
|
|
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.