JSON projections
@JsonProjection is the JDBC-backed projection store included in spring-ddd-cqrs-jdbc.
The read model is serialised to a JSON blob in a dedicated JDBC table — no Hibernate or JPA
dependency is required.
Individual properties can be promoted to indexed query columns with @QueryField,
enabling efficient derived finder methods.
Dependency
dependencies {
implementation("de.dwittkoetter:spring-ddd-starter-cqrs-jdbc:0.0.1-SNAPSHOT")
}
The starter brings in spring-ddd-cqrs-jdbc and all required transitive dependencies,
including the projection dispatch runtime from spring-ddd-cqrs.
Declaring a JSON projection
Annotate the projection class with @JsonProjection:
@JsonProjection(name = "account-summary", table = "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 identity of the projection — it survives class renames and acts as the
checkpoint key and (for GLOBAL-scope projections) the read-model key.
table is required when the read model declares any @QueryField columns (see below). |
@JsonProjection meta-annotates @Component, so no explicit @Bean declaration is needed.
Annotation attributes
| Attribute | Default | Purpose |
|---|---|---|
|
(required) |
Stable storage identity. |
|
|
|
|
|
Dedicated JDBC table name for this projection’s JSON storage.
Required when any |
|
|
When |
How the store works
On every @ProjectionListener invocation Spring DDD:
-
Loads the read model JSON blob from the JDBC table (or calls
@InitReadModelif no row exists for that id). -
Passes the deserialised read model to the listener method.
-
Serialises the updated read model back to JSON and writes it to the table.
-
Extracts each
@QueryFieldvalue and writes it to the corresponding query column in the same row.
All four steps run inside a REQUIRES_NEW transaction that starts after the originating
(command-handler) transaction commits.
The JSON blob is the source of truth for the full read-model state. Query columns are derived copies — efficient for querying and sorting, but the blob is what is deserialised when a read model is loaded.
Tables and schema creation
A JSON projection store uses two kinds of table:
- Read-model tables
-
The shared
PROJECTION_STOREtable holds the JSON blobs of every projection that declares no@QueryField, keyed by(read_model_id, projection_name). A projection that declares any@QueryFieldinstead gets its own dedicated table (see Dedicated table requirement). - Bookkeeping tables
-
PROJECTION_METADATA(per-projection checkpoint and replay generation),READ_MODEL_METADATA, andHIGH_WATER_MARKtrack replay progress and catch-up. These are shared by every projection store.
Whether Spring DDD creates these tables for you is governed by two independent properties that share the
same auto / none semantics: the JSON read-model tables (PROJECTION_STORE and any dedicated
@QueryField tables) by spring.ddd.cqrs.projection.json.table-creation, and the shared bookkeeping
tables by spring.ddd.cqrs.projection.jdbc.store.table-creation. This lets you, for example, have Flyway
own the read-model tables while the framework still auto-creates the bookkeeping tables.
auto(default)-
On startup the framework creates any of the tables above that do not yet exist, using the detected dialect’s DDL. Existing tables are left untouched — they are never altered, which is why changing a query column’s shape requires dropping its table (see Schema evolution).
none-
Spring DDD creates nothing; you own the schema and supply the DDL yourself (for example with Flyway or Liquibase). This is the common choice for production. See Database table schemas for the DDL reference.
The dialect is detected automatically; override it with spring.ddd.cqrs.projection.jdbc.store.dialect
if needed.
Query columns (@QueryField)
A property annotated with @QueryField is promoted to a dedicated, indexed column alongside
the JSON blob.
This makes the property directly queryable via a derived finder method on the repository
without a full table scan.
data class AccountSummary(
val id: AccountId,
@QueryField(unique = true) (1)
val accountNumber: String,
@QueryField(type = QueryFieldType.NUMBER) (2)
var balance: BigDecimal,
)
| 1 | unique = true adds a UNIQUE index and enables a single-result finder (returning T?). |
| 2 | NUMBER stores the value in a natively typed column with correct numeric ordering.
TEXT (default) stores it as VARCHAR(255) with lexicographic ordering. |
Supported property types
How a property value reaches its column depends on the column type.
For a TEXT column the value is reduced to a string. The following are accepted:
-
Known scalar types:
String,UUID,Long,Int,Short,Byte,Boolean,BigInteger,BigDecimal. -
Enums.
-
Single-property value objects wrapping one of the above.
-
Any type with a registered Spring
Converter<ThatType, String>bean.
For a typed column (NUMBER, TEMPORAL, BOOLEAN) the value is reduced to the column’s native
type, trying two steps in order:
-
Auto-unwrap — the property type itself, or a single-property value object unwrapped to its inner type, when that native type fits the column category (see Column types).
-
Native-target converter — a registered
Converter<ThatType, X>whose targetXmatches the column category: a numeric type (Int,Long,Short,Byte,BigInteger,BigDecimal) forNUMBER; ajava.timetype (Instant,LocalDate,LocalTime,LocalDateTime,OffsetDateTime,ZonedDateTime) forTEMPORAL;BooleanforBOOLEAN.
So a multi-property value object such as Money (which does not auto-unwrap) can back a NUMBER
column by registering a Converter<Money, BigDecimal> (or Converter<Money, Long>), and likewise
for the temporal and boolean categories.
At most one native-target converter may be registered per source type; an ambiguous set fails at
startup.
Any property that satisfies none of these rules causes a fast-fail error at startup.
A Converter<YourType, String> bean must be a concrete class or an object expression,
not a lambda assigned to a Converter<…> variable.
Spring’s ResolvableType cannot read the generic type arguments of a lambda, so a lambda converter is
silently skipped — the type is then treated as unregistered and Spring DDD fails at startup.
|
+
// ❌ Lambda — generic arguments are not resolvable; @QueryField fails at startup
@Bean
fun accountNumberConverter(): Converter<AccountNumber, String> = Converter { it.value }
// ✅ Named class — generic arguments available via reflection
class AccountNumberConverter : Converter<AccountNumber, String> {
override fun convert(source: AccountNumber): String = source.value
}
@Bean fun accountNumberConverter(): AccountNumberConverter = AccountNumberConverter()
// ✅ Object expression — also works
@Bean
fun accountNumberConverter() = object : Converter<AccountNumber, String> {
override fun convert(source: AccountNumber): String = source.value
}
The same rule applies to Converter<YourType, X> beans for typed (NUMBER/TEMPORAL/BOOLEAN)
columns.
Dedicated table requirement
A read model that declares any @QueryField must use a dedicated table:
@JsonProjection(
name = "account-summary",
table = "account_summary", (1)
)
class AccountSummaryProjection { ... }
| 1 | A specific table name is mandatory.
Query columns cannot share the default PROJECTION_STORE table — Spring DDD rejects such
a configuration at startup with a descriptive error. |
A table that carries query columns is owned by a single projection shape.
Two read models may only share the same table if their @QueryField declarations are identical;
if they name the same table but their query columns differ — in column name, SQL type, uniqueness,
or the overall set — Spring DDD fails at startup with a descriptive error naming both column sets.
Read models that declare no @QueryField are unaffected by both rules: they share the default
PROJECTION_STORE table, distinguished by their projection name.
Column types
The type attribute selects the SQL column kind.
The concrete dialect DDL is listed in Database table schemas.
QueryFieldType |
SQL kind | Notes |
|---|---|---|
|
|
Lexicographic ordering.
Correct for equality; not numerically ordered for |
|
|
Native numeric ordering and comparisons.
For |
|
|
Values are truncated to microsecond resolution on both write and query paths.
Equality, range, and |
|
Native boolean or equivalent |
|
Other @QueryField attributes
| Attribute | Default | Purpose |
|---|---|---|
|
|
|
|
property name in |
Column name override (e.g. |
|
|
Column kind: |
|
|
Total digit count for a |
|
|
Fractional digit count for a |
For derived finder methods and the full querying API over @QueryField columns,
see Querying read models.
Projection repository scanning
A JsonProjectionRepository bean is registered automatically for every @JsonProjection class
discovered within the @SpringBootApplication package tree — no extra annotation is required.
Use @EnableProjectionRepositories to override the scanned packages, for example when projection
classes live outside the application root package:
@Configuration
@EnableProjectionRepositories(basePackages = ["com.example.banking"])
class ProjectionConfig
When @EnableProjectionRepositories is present, the auto-scan backs off entirely: only the
packages you name are scanned.
The annotation resolves value, basePackages, and basePackageClasses (type-safe alternative
to string package names); when none of these attributes are set, the annotated class’s own package
is used.
Schema evolution
The JSON blob is the source of truth, so most schema changes are non-breaking.
- Adding a field
-
Add the property with a Kotlin default value. Existing stored blobs deserialise with the default on the next load; no SQL migration is required.
- Removing a field
-
Drop the property. Unknown JSON properties are silently ignored on deserialisation.
- Structural changes (rename, type change)
-
For changes that cannot be handled by a default value, trigger a full rebuild: delete the projection’s checkpoint row and restart. The framework replays the event log and reconstructs every read model from scratch.
- Adding a
@QueryFieldcolumn -
The corresponding DDL column must be present in the table before the application starts. Add the column to the schema first (with a
DEFAULT NULLor a suitable default), then add the@QueryFieldannotation and redeploy. After startup Spring DDD back-fills the column on the next write for each read model, and a full rebuild will populate historical rows. For the column DDL reference, see Database table schemas.
|
Single-node brute-force rebuild. When a restructuring also changes the query-column shape, the simplest offline path — for a single node with acceptable downtime — is:
With Dropping the table alone is not enough: the checkpoint lives in a separate metadata table, so without resetting it the projection still considers itself caught up and the recreated table stays empty. Conversely, an existing table is only ever created when absent — it is never altered — which is why a column-shape change needs the drop rather than a redeploy. |
Multi-node behaviour
The JSON 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 @JsonProjection 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.