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

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).

table

"" (shared default table)

Dedicated JDBC table name for this projection’s JSON storage. Required when any @QueryField column is declared (see Query columns (@QueryField)). Without it the projection stores its blobs in the shared PROJECTION_STORE table (or the table configured by spring.ddd.cqrs.projection.json.default-read-model-table-name).

publishCurrentState

true

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

How the store works

On every @ProjectionListener invocation Spring DDD:

  1. Loads the read model JSON blob from the JDBC table (or calls @InitReadModel if no row exists for that id).

  2. Passes the deserialised read model to the listener method.

  3. Serialises the updated read model back to JSON and writes it to the table.

  4. Extracts each @QueryField value 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_STORE table holds the JSON blobs of every projection that declares no @QueryField, keyed by (read_model_id, projection_name). A projection that declares any @QueryField instead gets its own dedicated table (see Dedicated table requirement).

Bookkeeping tables

PROJECTION_METADATA (per-projection checkpoint and replay generation), READ_MODEL_METADATA, and HIGH_WATER_MARK track 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:

  1. 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).

  2. Native-target converter — a registered Converter<ThatType, X> whose target X matches the column category: a numeric type (Int, Long, Short, Byte, BigInteger, BigDecimal) for NUMBER; a java.time type (Instant, LocalDate, LocalTime, LocalDateTime, OffsetDateTime, ZonedDateTime) for TEMPORAL; Boolean for BOOLEAN.

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

TEXT (default)

VARCHAR(255)

Lexicographic ordering. Correct for equality; not numerically ordered for BigInteger / BigDecimal.

NUMBER

BIGINT (Byte/Short/Int/Long), DECIMAL(38, 0) (BigInteger), or DECIMAL(precision, scale) (BigDecimal)

Native numeric ordering and comparisons. For BigDecimal, precision defaults to 38 and scale defaults to 10. Constraint: 1 ≤ precision ≤ 38, 0 ≤ scale ≤ precision. Affects only the query column; the JSON blob keeps full precision.

TEMPORAL

TIMESTAMP(6), DATE, or TIME(6) (inferred from Kotlin type)

Values are truncated to microsecond resolution on both write and query paths. Equality, range, and ORDER BY are reliable to microsecond precision. Sub-microsecond nanoseconds are dropped in the query column; the JSON blob retains full precision.

BOOLEAN

Native boolean or equivalent

true / false stored natively.

Other @QueryField attributes

Attribute Default Purpose

unique

false

trueUNIQUE index; enables single-result finders (returning T?). false → plain index; only collection-returning finders (List<T> / Page<T>) are valid.

column

property name in snake_case

Column name override (e.g. customerIdcustomer_id).

type

QueryFieldType.TEXT

Column kind: TEXT, NUMBER, TEMPORAL, or BOOLEAN.

precision

38

Total digit count for a BigDecimal NUMBER column (DECIMAL(precision, scale)). Ignored for all other types.

scale

10

Fractional digit count for a BigDecimal NUMBER column. Must satisfy 1 ≤ precision ≤ 38, 0 ≤ scale ≤ precision. Ignored for all other types.

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 @QueryField column

The corresponding DDL column must be present in the table before the application starts. Add the column to the schema first (with a DEFAULT NULL or a suitable default), then add the @QueryField annotation 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:

  1. Stop the application.

  2. Drop the projection’s read-model table and delete its checkpoint row.

  3. Restart.

With spring.ddd.cqrs.projection.json.table-creation left at its default (auto), the framework recreates the table with the current @QueryField shape on startup, and the reset checkpoint makes the projection replay the event log from scratch to repopulate it.

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.