JPA projections

@JpaProjection is the Hibernate-backed projection store included in spring-ddd-cqrs-jpa. The read model is a standard JPA @Entity with its own database table, persisted and loaded via Hibernate’s EntityManager. Querying the read model is done through plain Spring Data JPA repositories — JpaRepository, derived finder methods, JPQL, and specifications — without any framework-specific querying API.

This page covers the @JpaProjection store. If you want a read model without JPA or Hibernate, see @JsonProjection in spring-ddd-cqrs-jdbc (JSON blob over JDBC, no Hibernate). For aggregates on the write side (not projections), see JPA aggregates — both features live in spring-ddd-cqrs-jpa but serve different roles.

Dependency

dependencies {
    implementation("de.dwittkoetter:spring-ddd-starter-cqrs-jpa:0.0.1-SNAPSHOT")
}

The starter brings in spring-ddd-cqrs-jpa and all required transitive dependencies, including the projection dispatch runtime from spring-ddd-cqrs, the durable JDBC bookkeeping layer (spring-ddd-cqrs-projection-jdbc), and a JPA provider (Hibernate) — so a @JpaProjection runs on this starter alone. If you are already using the full bundle spring-ddd-starter-jpa, no additional entry is needed.

Declaring a JPA projection

Annotate the projection class with @JpaProjection:

@JpaProjection(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 storage identity of the projection — it survives class renames and acts as the checkpoint key and (for GLOBAL-scope projections) the read-model key.

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

Annotation attributes

Attribute Default Purpose

name

(required)

Stable storage identity.

scope

ProjectionScope.AGGREGATE

AGGREGATE (one read model per aggregate id) or GLOBAL (one singleton read model for the whole projection, keyed by the projection name). See GLOBAL scope.

publishCurrentState

true

When true, publishes the updated read model as a Spring application event after each save, enabling projection chaining.

The read model @Entity

The read model class must be annotated with @Entity. A non-entity read model under @JpaProjection is rejected at startup with a descriptive error — use @JsonProjection for JSON-over-JDBC storage without Hibernate.

@Entity
@Table(name = "account_summary")
data class AccountSummary(

    @EmbeddedId                  (1)
    var id: AccountId = AccountId(UUID(0, 0)),

    var balance: BigDecimal = BigDecimal.ZERO,
)
1 Use @EmbeddedId for value-object id types — here AccountId is an @Embeddable (@Embeddable data class AccountId(val value: UUID)). For a plain scalar id (String, UUID, Long, …) use @Id instead.

The entity must declare exactly one id field — either @Id (scalar) or @EmbeddedId (value object) — using field access; do not combine the two on one field. Composite keys (@IdClass) are not supported.

Spring DDD stamps the id onto the read model before every EntityManager.merge(), so both inserts and updates are handled by the same code path. You do not need to set the id yourself inside a @ProjectionListener.

Hibernate manages the table DDL according to your spring.jpa.hibernate.ddl-auto setting. The entity participates in the full JPA mapping model: @Column, @Embedded, @Convert, Hibernate type annotations, and all other mapping annotations work as usual.

Querying the read model

Declare a JpaRepository for the read model to query it:

interface AccountSummaryRepository : JpaRepository<AccountSummary, AccountId> {
    fun findByBalance(balance: BigDecimal): List<AccountSummary>
}

The full Spring Data JPA query API is available: derived finder methods, @Query (JPQL/native SQL), JpaSpecificationExecutor, and QuerydslPredicateExecutor. Spring DDD imposes no restrictions on the repository.

GLOBAL scope

A GLOBAL-scoped @JpaProjection maintains a single read-model entity per projection, used when the projection aggregates state across all aggregate instances (for example, a running total or an event counter).

The framework stamps the entity’s @Id with the projection’s name attribute, so a GLOBAL projection’s read model must declare a String @Id:

@Entity
@Table(name = "account_totals")
data class AccountTotals(
    @Id var id: String = "",                          (1)
    var totalBalance: BigDecimal = BigDecimal.ZERO,
)

@JpaProjection(name = "account-totals", scope = ProjectionScope.GLOBAL)
class AccountTotalsProjection {
    @InitReadModel
    fun init() = AccountTotals()

    @ProjectionListener
    fun on(event: MoneyDeposited, totals: AccountTotals) {
        totals.totalBalance += event.amount.value
    }
}
1 The id is stamped by the framework with the projection name. The field must be a String; any other type is rejected at startup.

How the store works

On every @ProjectionListener invocation Spring DDD:

  1. Calls EntityManager.find() to load the read model entity by id (or calls @InitReadModel if no row exists yet).

  2. Passes the managed entity to the listener method.

  3. Calls EntityManager.merge() to persist the updated entity. Both inserts and updates go through merge().

All steps run inside a REQUIRES_NEW transaction that starts after the originating (command-handler) transaction commits.

Querying the read model from the application side is done entirely through the JpaRepository you declare — the Spring DDD store does not expose a query API.

Where state lives

A JPA projection persists across two layers:

The read-model entity

Your @Entity table holds the read-model data, managed entirely by Hibernate. Its DDL follows spring.jpa.hibernate.ddl-auto, like any other JPA entity.

The projection bookkeeping

The catch-up checkpoint, replay generation, and high-water mark are not stored in your entity table. They live in the shared relational projection-store tables — PROJECTION_METADATA, READ_MODEL_METADATA, and HIGH_WATER_MARK — provided by spring-ddd-cqrs-projection-jdbc. This bookkeeping layer backs every durable projection store, JSON and JPA alike, and its table creation is governed by spring.ddd.cqrs.projection.jdbc.store.table-creation (auto by default), independently of Hibernate’s ddl-auto. See Tables and schema creation.

Both spring-ddd-starter-cqrs-jpa and the full spring-ddd-starter-jpa bundle wire this JDBC bookkeeping layer for you. It is also why the projection rebuild below — "delete the checkpoint row" — targets PROJECTION_METADATA, not your entity table.

Schema evolution

The read model is a JPA @Entity, so schema evolution follows standard relational migration practices.

Adding a field

Add the column to the database first (via a DDL migration script or tool), then add the corresponding property to the entity class. Hibernate’s DDL auto-update can add the column for you in development, but production deployments should apply explicit migrations before updating the entity class.

Removing a field

Remove the column from the schema first (or make the property @Transient / omit it and leave the column), then drop the property from the entity.

Renaming or changing the type of a field

Apply the DDL migration (rename column, change type, backfill) before updating the entity class. A projection rebuild (delete the checkpoint row and restart) replays all events from the event store and reconstructs every read model from scratch — provided the projection is fed by event-sourced aggregates (see Replay and JPA-aggregate sources below). This is useful when the read-model semantics change rather than just the column shape.

This is the key difference from @JsonProjection, where the JSON blob lets most field additions and removals happen without any DDL.

Replay and JPA-aggregate sources

Replay re-reads the event store — the durable, ordered log written by @EventSourced aggregates. It therefore works only for projections fed by event-sourced aggregates.

A projection fed by JPA-aggregate events (@Entity / AbstractAggregateRootEntity) has no persistent event log: those domain events are published live after each transaction and are never stored in the event store. Replay cannot reconstruct such a projection’s history from scratch.

When you introduce a @JpaProjection driven by JPA-aggregate events, seed its initial read-model state manually. Because JPA aggregates and their projections run in the same application, querying the existing aggregate tables directly (for example with a one-off SQL migration) is usually the simplest path. From that point forward the projection stays current through live after-commit delivery; there is no built-in mechanism to rebuild it from scratch.

Replay for event-sourced projections requires spring-ddd-cqrs-bridge on the classpath alongside spring-ddd-starter-cqrs-jpa. The ProjectionReplayCoordinator — a ProjectionManager that re-reads all events from the event store and replays them through the projection dispatcher — ships in spring-ddd-cqrs and is auto-configured by spring-ddd-starter-cqrs (a transitive dependency of spring-ddd-starter-cqrs-jpa). It activates only when an OrderedSource over the event store is present, which the bridge supplies; without the bridge there is no replay source and the coordinator stays inert. The full spring-ddd-starter-jpa bundle brings the bridge transitively:

implementation("de.dwittkoetter:spring-ddd-starter-jpa:0.0.1-SNAPSHOT")

Multi-node behaviour

The JPA store uses a shared, lease-coordinated metadata store to ensure a single active consumer per projection across the cluster. Only one node holds the lease and processes events for a given projection at a time; the others stand by and take over if the lease lapses.

This is different from @InMemoryProjection, where every node rebuilds independently.

Delivery, error handling, and chaining

The @JpaProjection store participates in the same delivery and error-handling runtime as all projection stores. Catch-up replay, error handling and the onError policy (HALT / SKIP), and projection chaining are shared mechanics covered in Delivery & error handling and Chaining projections.

Projection metrics, health indicators, and the actuator endpoint are available when the spring-ddd-cqrs-actuator-starter is on the classpath.