Writing a projection

A projection is a class whose listeners turn messages into read models. This page covers the listener, how a message picks the read model it changes, how read models are created and deleted, and what a listener can read besides the message.

The listener

A projection class carries its store annotation and has @ProjectionListener methods. The store annotation, @InMemoryProjection, @JpaProjection or @QueryProjection, also makes the class an ordinary Spring @Component. Component scanning registers it, it can take its dependencies through its constructor, and it must stay a singleton bean. A listener takes the message, then the read model it changes, and mutates the read model in place; a value it returns is ignored. The store pages give each store’s rules for the read model; the first example is a JPA read model.

@Entity
class AccountSummary(
    @EmbeddedId var accountId: AccountId = AccountId(),
    @Version var version: Long? = null,
    var owner: String = "",
    var balance: BigDecimal = BigDecimal.ZERO,
)

@JpaProjection(name = "account-summary")
class AccountSummaryProjection {

    @ProjectionListener
    fun on(event: AccountOpened, summary: AccountSummary) {
        summary.owner = event.owner
    }

    @ProjectionListener
    fun on(event: MoneyDeposited, summary: AccountSummary) {
        summary.balance += event.amount.value
    }
}

@Payload and @ReadModel mark the message and the read model; both are optional when the message comes first. A listener that takes the read model first needs both:

@ProjectionListener
fun on(@ReadModel summary: AccountSummary, @Payload event: MoneyDeposited) {
    summary.balance += event.amount.value
}

The application does not start when a listener:

  • takes no read model, unless it is a @DeleteReadModel listener;

  • declares its read model nullable, or types it Any or by a type parameter;

  • marks two parameters @ReadModel;

  • has a parameter that is neither the message, the read model nor metadata, other than an @AdHocUpstream one (see Looking up an upstream read model);

  • is a suspend function.

Keys

A message updates the read model stored under its key.

The default key

The default key is the message’s @MessageKey, which @AggregateId implies. A projection whose messages carry one needs no declaration.

Declaring a key

@ReadModelKey(type, value) on the projection names a message type and a SpEL expression evaluated against the message. The annotation is repeatable, one declaration per type. A declaration on a supertype or an interface covers every message assignable to it, and the most specific declaration wins. A projection class inherits the declarations of its superclasses, and its own declaration for a type replaces the inherited one.

@JpaProjection(name = "branch-balances")
@ReadModelKey(type = MoneyDeposited::class, value = "branchId")
@ReadModelKey(type = TransferBooked::class, value = "{sourceBranchId, targetBranchId}")
class BranchBalanceProjection {
    // listeners for MoneyDeposited and TransferBooked
}

The expression accepts property navigation and operators:

value Result

accountId

a property of the message

transfer.sourceAccountId

a nested property

holder?.id

safe navigation: null, so the message is skipped, when holder is null

holder?.id ?: 'walk-in'

a default for null

priority ? branchId : 'central'

a condition

region + '-' + branchId

operators, such as string concatenation

transfers[0].targetAccountId, limits['daily']

list and map indexing

{sourceBranchId, targetBranchId}

an inline list: one read model for each

transfers.![targetAccountId]

a projection: one read model per transfer’s target account

transfers.?[amount > 0].![targetAccountId]

a selection, then a projection

'all'

a constant: every message updates the one read model

The expression does not accept method calls, type references, constructors, bean references, functions, assignment, or variables other than #this and #root, which both name the message, and it sees the message only, never its metadata. The application does not start when an expression uses one of them.

Startup proves that every message type a listener can receive resolves to exactly one key, or the application does not start. A listener on a concrete class, or on a sealed class or interface, needs nothing beyond the key each concrete message carries. A listener on any other interface or abstract class needs a @ReadModelKey that covers it or, for an abstract class, a @MessageKey every subclass inherits.

The read model’s own id

@ReadModelKey on a property of the read model, without type and value, marks the read model’s own id: the key the read model is stored under, declared the same way for every store. A read model marks at most one own id, which is not nullable and reduces to a string, or the application does not start. A message’s key and a read model’s own id match when both reduce to the same string.

class AccountSummary(
    @ReadModelKey var accountId: AccountId = AccountId(),
    var owner: String = "",
    var balance: BigDecimal = BigDecimal.ZERO,
)

The own id is optional, and for an in-memory or query repository it decides two things:

  • the id type of the read model’s repository, which is the own id’s type;

  • whether Spring Data REST exports the repository: it exports one only when the read model’s own id is a property with a backing field, not a computed one; see Spring Data REST.

An application’s own JpaRepository takes the @Id type as its id type and is exported as Spring Data REST exports any repository. On a JPA read model, the own id is the @Id or @EmbeddedId field, which the JPA store sets to the key before every write; see The read model entity. The in-memory and query stores do not set it, so set it in an @InitReadModel factory that takes the id; see Creating and deleting read models.

Several read models from one message

A key expression that yields a collection or an array updates one read model per element; null elements and repeated keys are dropped. A null or empty result skips the message for this projection. A key holds at most 255 characters: a message whose key is longer is retried and then goes to the projection’s error handler, while the other read models keep updating.

One read model for everything

A read model that sums up every account, such as a bank-wide total, declares a constant key:

@Entity
class BankTotal(
    @Id var name: String = "",
    @Version var version: Long? = null,
    var deposited: BigDecimal = BigDecimal.ZERO,
)

@JpaProjection(name = "bank-totals")
@ReadModelKey(type = MoneyDeposited::class, value = "'all'")
class BankTotalProjection {

    @ProjectionListener
    fun on(event: MoneyDeposited, total: BankTotal) {
        total.deposited += event.amount.value
    }
}

Every deposit, whichever account it is paid into, updates the one read model stored under all. In a store shared by partition, such as this JPA one, one key means one partition: one instance at a time applies every message the projection receives, one after another, in order per source. That is the projection’s whole throughput, and adding instances does not raise it. Give a projection like this nothing else to do, and keep read models that can be split under keys of their own; see Partitions and Ordering.

Creating and deleting read models

A read model that is not stored yet is created from its no-arg constructor, or from an @InitReadModel factory. A projection declares at most one factory. It takes nothing or the read model’s id, and returns the read model’s type, not nullable. Without a factory, the application does not start when the read model is an interface, an abstract class or a class without a no-arg constructor.

@InitReadModel
fun init(id: AccountId) = AccountSummary(accountId = id)

A @DeleteReadModel listener removes the read model:

@DeleteReadModel
fun on(event: AccountClosed) = Unit

Every listener the message reaches runs first, in order, the deleting ones and the updating ones alike; then the read model is deleted instead of saved. When nothing is stored under the key, nothing happens: no listener runs and no read model is created. The next message for the same key creates a fresh read model.

@DeleteReadModel(condition = "…") deletes only when the condition holds; a listener whose condition is false is left out, as if it did not match. The condition is a SpEL expression with the message as root and every parameter as a variable by its name, and the read model is null when none is stored. It must yield a Boolean, so navigate null-safely and compare:

@DeleteReadModel(condition = "#summary?.balance == 0")
fun on(event: AccountClosed, summary: AccountSummary) = Unit

The application does not start when a condition does not parse, and an apply fails when a condition fails to evaluate.

Several listeners for one message

Every listener whose message parameter the message is assignable to runs. They run by @Order ascending, a listener without one last, then the one on the most general type first, then in a stable order. So a general listener can set defaults that a specific one refines.

Message metadata

A listener can take MessageMetadata, or a single value with @MetadataValue("key"). On a listener that takes another projection’s read model, the metadata is that of the message whose apply made the change. It is empty when that message carried none, so declare a @MetadataValue such a change may lack as nullable: a non-nullable one fails the apply. See Message metadata.

Read models are JSON

An in-memory projection copies its read models as JSON with the framework’s own JSON mapper, never the application’s Jackson settings. A persistent one publishes every change as JSON with the same mapper to its read-model topic, which is on by default; see Read-model topics. A read model must therefore survive a JSON round trip.

To change how read models are written, declare a JsonMapper bean named readModelJsonMapper:

@Bean("readModelJsonMapper", defaultCandidate = false)
fun readModelJsonMapper(): JsonMapper = JsonMapper.builder()
    .addModule(kotlinModule())
    .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
    .build()

It replaces the framework’s mapper for every store. Keep defaultCandidate = false: without it, the bean also replaces the application’s own JsonMapper. Read models already stored or published were written with the old mapper: keep them readable with the new one, or rebuild the projections that store or read them; see Rebuild and schema evolution.

A read model a listener takes from another projection is input only: changing it changes nothing; see Projections on projections.