Building projections
A projection (read model) is a query-optimised view of domain state, kept up to date by handling domain events. In a CQRS system, projections are how the read side stays in sync with the write side: when a command changes aggregate state, the resulting events are delivered to each projection after the originating transaction commits, and the projection uses them to update its read model accordingly.
Spring DDD’s projection dispatch runtime is provided by spring-ddd-cqrs.
Three storage strategies are available; all share the same dispatch model.
Choosing a store
@InMemoryProjection(no additional dependency)-
Read models are held in JVM heap and rebuilt from the event log on every startup. Use this for zero-config, no-database read sides where a short rebuild window on startup is acceptable. See In-memory projections.
@JsonProjection(spring-ddd-cqrs-jdbc)-
The read model is serialised to a JSON blob in a JDBC table; individual fields can be promoted to indexed query columns for efficient querying. Use this for durable read models with no JPA/Hibernate requirement. See JSON projections.
@JpaProjection(spring-ddd-cqrs-jpa)-
The read model is a JPA
@Entitywith its own table, managed by Hibernate. Use this when the read model needs full Spring Data JPA query power or must live alongside an existing JPA schema. See JPA projections.
All three annotations meta-annotate @Component, so no explicit @Bean declaration is required.
Writing a projection
The projection class
Annotate the class with the chosen store annotation.
The required name attribute is the stable identity of the projection — it serves as the
checkpoint key, survives class renames, and is used as the read-model key for
GLOBAL-scope projections.
@InMemoryProjection(name = "account-summary")
class AccountSummaryProjection {
@InitReadModel
fun init(id: AccountId) = AccountSummary(id = id) (1)
@ProjectionListener
fun on(event: AccountOpened, summary: AccountSummary) { (2)
summary.owner = event.owner
summary.balance = event.initialBalance.value
}
@ProjectionListener
fun on(event: MoneyDeposited, summary: AccountSummary) {
summary.balance += event.amount.value
}
}
| 1 | @InitReadModel is the factory the framework calls when no existing read model is found for a
given id.
Declare it with an id parameter to seed the identity at construction time (the id is derived
from the event — see Resolving the read-model id); use a no-arg form when the id is not
needed at construction.
If absent, the framework calls the read model’s no-arg constructor instead. |
| 2 | The framework loads (or initialises) the read model, invokes the method, then saves the result —
all in a REQUIRES_NEW transaction that runs after the originating transaction commits. |
@ProjectionListener method shapes
A @ProjectionListener method may take two forms:
fun on(event: E, readModel: T)-
State-managing: the framework loads (or initialises) the read model, passes it to the method, then saves the result. The read model must expose mutable properties so the method can update it in-place.
fun on(event: E)-
Side-effect only: no read model lifecycle is involved (useful for publishing notifications or updating external systems).
Use @Order to control the invocation sequence when multiple methods handle the same event type.
Annotate a method with @IgnoreOnReplay to skip it during catch-up replay (for live-only side
effects such as sending notifications).
By default the two parameters are positional: the first is the incoming event — or, in
projection chaining, an upstream read model — and the first
remaining non-metadata parameter is the read model this listener maintains. The canonical
fun on(event, readModel) shape shown above is unchanged.
Annotating the maintained read-model parameter with @ReadModel (package de.dwittkoetter.ddd.annotation)
marks it explicitly, so it may appear at any position; @Payload marks the first parameter the same way.
Once a parameter is marked, an unexpected parameter that is neither of those two nor recognized metadata
fails fast at startup instead of being silently misread as the maintained read model.
@ProjectionListener
fun on(
event: MoneyDeposited,
@MetadataValue("tenant-id") tenantId: String?,
@ReadModel summary: AccountSummary, (1)
) {
summary.balance += event.amount.value
}
| 1 | @ReadModel makes the read-model binding explicit regardless of parameter order.
Any further parameter that is neither the event, the read model, nor recognized metadata
fails fast at startup with a clear error. |
Reading metadata
Either shape accepts trailing metadata parameters, giving the full signature grammar:
fun on(event: E[, readModel: T][, metadata params…])
A metadata parameter is either @MetadataValue("key") value: V (one key) or metadata:
MessageMetadata (the whole envelope).
Resolution follows the same rules as any other supported handler: an absent key with a
non-nullable parameter throws MissingMetadataException, a value that doesn’t fit the parameter
type throws MetadataTypeMismatchException, a nullable parameter receives null when the key is
absent, and a MessageMetadata parameter always binds (MessageMetadata.EMPTY at worst).
@ProjectionListener
fun on(
event: MoneyDeposited,
summary: AccountSummary,
@MetadataValue("tenant-id") tenantId: String?, (1)
) {
summary.balance += event.amount.value
}
| 1 | For an event-sourced MoneyDeposited, tenantId is the value persisted with the event when
the originating command was dispatched — identical whether this listener runs on live delivery
or during a rebuild.
See Message metadata for the full metadata model, including
why live-only events (published via ApplicationEventPublisher or for example from a JPA @Entity
aggregate) do not replay. |
Resolving the read-model id
For an AGGREGATE-scope projection (the default), the framework has to determine which read
model each incoming event updates.
It resolves that id from the event, applying the first rule that matches:
-
A
@ReadModelKeydeclared on the projection class, if one covers the event type. -
Otherwise, a property of the event annotated
@AggregateId. -
Otherwise, a property annotated
jakarta.persistence.@Id. -
Otherwise, a property annotated
jakarta.persistence.@EmbeddedId.
The resolved id is what @InitReadModel receives, and the key under which the read model is loaded,
saved, and deleted.
If none of these rules can supply an id for a state-managing listener’s event type, the application
fails fast at startup with a message naming the offending event.
Most event-sourced events already carry the aggregate id as an @AggregateId property, so no extra
wiring is needed:
data class AccountOpened(
@AggregateId val id: AccountId,
val owner: OwnerId,
val initialBalance: Money,
)
Declare a @ReadModelKey on the projection class when the read-model key is not a plain
@AggregateId property. type names the covered event class; value is a pure SpEL expression
evaluated against the event. The common case maps a single event field to the key:
@JpaProjection(name = "account-summary")
@ReadModelKey(type = MoneyDeposited::class, value = "accountId") (1)
class AccountSummaryProjection { /* ... */ }
| 1 | MoneyDeposited carries the account id as a plain accountId field rather than @AggregateId,
so this key routes the event to the right AccountSummary. The expression may be written as a
bare property path ("accountId") or explicitly rooted with #this. ("#this.accountId") —
the two are equivalent, since the root object of the expression is the event itself. |
The same mechanism can also fan one event out to several read models, by returning a collection instead of a scalar:
@JpaProjection(name = "account-summary")
@ReadModelKey(type = TransferCompleted::class, value = "{sourceAccount, targetAccount}") (1)
class AccountSummaryProjection { /* ... */ }
| 1 | The expression is evaluated against the event and its result becomes the id(s) to update: a
single scalar targets one read model, a collection fans out to several, and null or an empty
collection skips the event for this projection. The expression may navigate properties, index,
use elvis/ternary, and project over collections (lineItems.![productId]); it runs in a
read-only data-binding context, so bean references, type references, constructors, and method
calls are unavailable — the key can never depend on application state. A class may repeat
@ReadModelKey to cover more than one event type. |
|
Property navigation reads through getters, so a computed property on the event is a valid key target,
referenced by name — including one that constructs the id type from a raw field
( |
Keying more than one event type
A projection that folds together events from more than one source often needs a different key
expression per event type. Repeat @ReadModelKey, once per covered type:
data class AccountOpened(
val accountId: AccountId,
val owner: OwnerId,
)
data class SuspiciousActivityFlagged(
val flaggedAccount: AccountId,
val reason: String,
)
@JpaProjection(name = "account-risk-log")
@ReadModelKey(type = AccountOpened::class, value = "accountId") (1)
@ReadModelKey(type = SuspiciousActivityFlagged::class, value = "flaggedAccount") (2)
class AccountRiskLogProjection {
@ProjectionListener
fun on(event: AccountOpened, log: AccountRiskLog) {
log.owner = event.owner
}
@ProjectionListener
fun on(event: SuspiciousActivityFlagged, log: AccountRiskLog) {
log.flags += event.reason
}
}
| 1 | AccountOpened carries the account id as accountId, not @AggregateId — this key routes it
to the right AccountRiskLog. |
| 2 | SuspiciousActivityFlagged names the same logical account differently (flaggedAccount); each
@ReadModelKey is evaluated only against the event type it names, so the field name is free to
vary per event. |
Kotlin compiles a repeated annotation into its container annotation. The same declaration can be
written explicitly with @ReadModelKeys, which some readers find clearer when there are several
keys to scan at once — the two forms are equivalent:
@JpaProjection(name = "account-risk-log")
@ReadModelKeys(
[
ReadModelKey(type = AccountOpened::class, value = "accountId"),
ReadModelKey(type = SuspiciousActivityFlagged::class, value = "flaggedAccount"),
],
)
class AccountRiskLogProjection { /* ... */ }
A GLOBAL-scope projection has a single read model keyed by the projection’s name, so id
resolution does not apply — declaring a @ReadModelKey on one is a configuration error that fails
fast at startup, since a single read model needs no key.
@AggregateId / @Id / @EmbeddedId here are read on the incoming event — or, in
projection chaining, the upstream read model — to choose which
downstream read model to update. That is distinct from the @Id / @EmbeddedId on the read-model
entity being written, which is its storage key (see JPA projections).
When that value is an upstream read model, its own @Id becomes the downstream key by default, so the
downstream is co-keyed 1:1 to the upstream — a derived view that reshapes or enriches it. Sharing a key
does not make them the same read model: they are different types in different projection namespaces.
Declare a @ReadModelKey when the downstream is not 1:1 with the upstream (to aggregate or re-key).
|
Polymorphic listeners
A @ProjectionListener parameter is not restricted to the event’s exact class: it may declare a
supertype or interface, and the framework matches by assignability. Every listener whose parameter
type the concrete event is assignable to fires — a listener on a shared supertype runs in
addition to an exact listener on the concrete event, not instead of it.
sealed interface AccountEvent {
val id: AccountId
}
data class AccountOpened(
@AggregateId override val id: AccountId,
val owner: OwnerId,
val initialBalance: Money,
) : AccountEvent
data class MoneyDeposited(
@AggregateId override val id: AccountId,
val amount: Money,
) : AccountEvent
@InMemoryProjection(name = "account-event-log")
class AccountEventLogProjection {
@ProjectionListener
fun on(event: AccountEvent, log: AccountEventLog) { (1)
log.entries += event.toString()
}
@ProjectionListener
fun on(event: MoneyDeposited, log: AccountEventLog) { (2)
log.moneyMovements += event.amount.value
}
}
| 1 | Fires for every AccountEvent leaf — one listener folds the whole sealed hierarchy into a
single append-only log. |
| 2 | Fires only for MoneyDeposited. Both listeners run for a MoneyDeposited event, in the order
described below. |
When several listeners match the same event, invocation order is:
-
@Orderascending (default0). -
Most-general first — a listener on a supertype or interface runs before a listener on one of that type’s subtypes.
-
A stable name order, to break remaining ties.
This lets a general listener apply defaults and a more specific listener refine them afterwards,
unless an explicit @Order says otherwise.
@ReadModelKey does not follow the same accumulation rule: it is single-winner, not
fan-out-by-assignability. When more than one key could cover an event, the single
most-specific one wins; two unrelated key types both covering the same event is
an ambiguity that fails fast at startup (or on first dispatch, if the ambiguity can only be
detected then). A key — even one declared on a supertype — always takes precedence over an
@AggregateId property on the concrete event.
A state-managing listener keyed on a supertype or interface still needs an id for every concrete event that can arrive at it. Supply one of:
-
an id property annotated
@AggregateIdon the interface itself — the id-source lookup walks supertypes, so for projection-side resolution an implementing event only needs tooverridethe property; event-sourced events keep the annotation on the concrete class anyway because aggregate-side routing does not walk supertypes (see the note below); -
a
@ReadModelKeycovering the supertype; -
a sealed hierarchy, so the framework can verify at startup that every leaf has an id source.
An open interface (one whose implementations the framework cannot enumerate) fed only through projection chaining is validated against the id already resolved by the upstream read model, rather than requiring an id source of its own.
|
|
On the event-sourcing side, an aggregate’s own @AggregateId-based routing does not walk
supertypes: each concrete event class must still carry @AggregateId directly for the event-store
reconstruction path. The supertype walk described above applies only to projection-side id
resolution.
Deleting a read model
Annotate a listener with @DeleteReadModel to remove the read model from the store instead of
saving it after the method returns.
The method must still declare the read model parameter so the body can shape the read model’s
final state before it is removed.
That final state only matters for projection chaining: if the projection has
publishCurrentState enabled, the framework publishes this final state as a Spring event so
downstream (chained) projections observe the read model’s last value; otherwise the read model is
simply removed and nothing is published.
publishCurrentState is a per-store attribute (its default differs between stores) — see the
In-memory, JSON, and
JPA projection pages.
@DeleteReadModel
fun on(event: AccountClosed, summary: AccountSummary) {
// optionally shape the final state published to chained projections
// (only when publishCurrentState is enabled)
}
A re-delivered delete is idempotent.
After deletion, the next event for the same id re-initialises a fresh read model via @InitReadModel
(or the no-arg constructor) and applies normally.
Fetching and evolving a read model
Fetching
Read models are retrieved through a generated repository.
Querying read models covers JsonProjectionRepository,
InMemoryProjectionRepository, derived finder methods, and predicate queries in full.
Evolving a read model’s shape
Schema evolution is store-specific — see In-memory projections, JSON projections, or JPA projections for details.
What comes next
Explore the three store types —
In-memory projections,
JSON projections, and
JPA projections — then head to
Querying read models for finder methods and predicate queries.
Delivery & error handling covers delivery guarantees,
catch-up concurrency, error handling, and the onError policy.
Chaining projections covers dependent projections.