JPA store
A @JpaProjection keeps its read models in the application’s own JPA entity table.
They survive a restart, and the projection is shared by partition across instances.
Setup
Add spring-ddd-starter-cqrs-jpa.
spring.ddd.cqrs.projection.jpa.enabled defaults to true; with false the store is left out, and a @JpaProjection then fails the startup.
The read model entity
The read model is the application’s own @Entity: Hibernate or the application’s migrations create and change its table, never the framework.
It follows these rules, and a read model that breaks one fails the startup with a message naming it:
-
It is an entity of exactly one persistence unit.
-
It has exactly one
@Idor@EmbeddedId, on a field, and no@IdClass.The framework sets it to the read model’s key before every write, so a listener never sets it.
-
It has a
@Versionof a nullable type, such asLong?.A
nullversion tells a new read model from a stored one, and every write is checked against it. -
Its entity hierarchy backs one projection: no other
@JpaProjectionuses an entity of the same hierarchy. -
It has a no-arg constructor, or the projection declares an
@InitReadModelfactory; see Creating and deleting read models.
A @ReadModelKey own id on the read model is optional; when present, it marks the @Id field; see The read model’s own id.
@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", errorHandler = DeadLetterKeyErrorHandler::class)
class AccountSummaryProjection {
// listeners, see Writing a projection
}
Repository
Query the read models through the application’s own Spring Data JpaRepository.
Its reads pass, and its writes (save…, delete…, remove…, flush…, update… and @Modifying queries) throw ReadModelWriteRefusedException: a read model changes only through its projection.
interface AccountSummaryRepository : JpaRepository<AccountSummary, AccountId> {
fun findByOwner(owner: String): List<AccountSummary>
}
To serve the read models over HTTP, see Read models over HTTP.
Transactions
The listener runs in a JPA transaction of the entity’s persistence unit: the active one, or one opened for it. An active transaction of any other kind fails the apply, since the write would commit apart from it. When the entity’s persistence unit is that of the application’s transaction manager, a change commits with the projection’s checkpoint, so it is applied exactly once; on another unit it commits on its own; see Two delivery paths.
Associations and JSON
While the projection’s read-model topic is written, which it is by default, every change is written as JSON with the framework’s mapper, the whole entity and its associations included; see Read models are JSON.
-
A bidirectional association serializes without end and fails the apply: mark one side
@JsonIgnore, or mark the two sides@JsonManagedReferenceand@JsonBackReference. -
A lazy association is loaded on every change, since the whole entity is written.
Do not use @Immutable
Do not mark the read model @Immutable.
Hibernate then ignores every change to it without an error: the read model keeps the state it was created with, while the projection moves on.
Ceilings
The store saves the way Spring Data JPA’s own repositories do, and shares their limits when two writers change one read model at once, which can happen while a projection moves from one instance to another and its changes commit apart from its checkpoints:
-
a save based on a read model another writer has deleted and created again overwrites the new one;
-
when two writers create the same read model, the second fails on the duplicate id, and its message is delivered again;
-
a delete does not notice a save another writer made after the delete read the read model.