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 @Entity with 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:

  1. A @ReadModelKey declared on the projection class, if one covers the event type.

  2. Otherwise, a property of the event annotated @AggregateId.

  3. Otherwise, a property annotated jakarta.persistence.@Id.

  4. 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 (val accountId: AccountId get() = AccountId(rawId)). That is the escape hatch when a key needs derivation the expression syntax cannot express. Keep such a getter a pure function of the event (no clock, randomness, or external state): under partitioning the key is evaluated off-thread to route the event, so a non-deterministic id would scatter an aggregate’s events across partitions.

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:

  1. @Order ascending (default 0).

  2. Most-general first — a listener on a supertype or interface runs before a listener on one of that type’s subtypes.

  3. 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 @AggregateId on the interface itself — the id-source lookup walks supertypes, so for projection-side resolution an implementing event only needs to override the 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 @ReadModelKey covering 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.

@InMemoryProjection rebuilds every read model by replaying the full event log on each startup, so a supertype-keyed read model must be provably replayable: the framework must be able to resolve its id the same way every time, from the same source. Prefer a typed supertype (ideally a sealed hierarchy) over listening on Any — an Any-typed state-managing listener has no principled id source and is discouraged.

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.