Skip to main content
Version: Next

JSON Support

Storm provides first-class support for JSON columns, allowing you to store and query JSON data directly in your entities. Annotate a field with @Json and Storm handles serialization/deserialization automatically.

Installation​

Storm supports two JSON serialization libraries. Choose the one that fits your project:

Jackson (Kotlin & Java)​

Works with both Kotlin and Java projects. Two variants are available, matching the two major Jackson versions. Both modules require Jackson to be present on the classpath; they do not bring Jackson as a transitive dependency. If you are using Spring Boot, Jackson is already included and the version is managed for you: Spring Boot 3.x ships with Jackson 2, while Spring Boot 4+ ships with Jackson 3. Choose the Storm module that matches your Jackson version. If you are not using Spring Boot, add Jackson to your project directly alongside the corresponding Storm module.

Jackson 2 (requires Jackson 2.17+):

// Gradle (Groovy DSL)
implementation 'st.orm:storm-jackson2:1.15.0'
<!-- Maven -->
<dependency>
<groupId>st.orm</groupId>
<artifactId>storm-jackson2</artifactId>
<version>1.15.0</version>
</dependency>

Jackson 3 (requires Jackson 3.0+):

// Gradle (Groovy DSL)
implementation 'st.orm:storm-jackson3:1.15.0'
<!-- Maven -->
<dependency>
<groupId>st.orm</groupId>
<artifactId>storm-jackson3</artifactId>
<version>1.15.0</version>
</dependency>

The two modules are mutually exclusive on the classpath, one of the four paired-module choices listed in One Per Classpath. Both provide the same public API (st.orm.jackson package), so switching between them requires only changing the dependency.

Kotlinx Serialization (Kotlin)​

A Kotlin-native option with compile-time safety. Requires the kotlinx-serialization Gradle plugin.

plugins {
kotlin("plugin.serialization") version "2.0.0"
}

dependencies {
implementation("st.orm:storm-kotlinx-serialization:1.15.0")
}

Storm auto-detects the serialization library at runtime. Just add the dependency and it works.

Storm compiles against kotlinx-serialization 1.7.3 and uses only its stable core API, so any 1.7.3 or newer release works: your project's dependency resolution picks the version, and Storm floats along with it.

Which Module Converts a Field​

An application may carry both storm-kotlinx-serialization and a Jackson module, kotlinx for its Kotlin types and Jackson for a client library. The module that converts a @Json field then follows from the field, never from classpath order:

  • A converter the field selects itself, with @Convert, wins over any JSON module.
  • kotlinx.serialization converts a field whose type it has a serializer for: a @Serializable type, a type registered in a serializers module, or a collection or Ref of one.
  • Jackson converts the remaining @Json fields, such as a Java record or a Kotlin class without @Serializable.

A @Json field no module claims is refused when the entity is first used, naming the modules; with kotlinx.serialization alone this is a field of a type without a serializer. Two converter providers that claim one field without an order between them are refused with both named.


JSON Columns​

Use @Json to map a field to a JSON column.

data class User(
@PK val id: Int = 0,
val email: String,
@Json val preferences: Map<String, String>
) : Entity<Int>

The preferences field is automatically serialized to JSON when writing and deserialized when reading.

JSON Columns in the Metamodel​

A JSON column is stored as text while the record holds a map, a list, or a structured object. The metamodel keeps both: it addresses the column by its stored type, so a predicate compares against the stored JSON, while value extraction returns what the record holds.

// Generated for @Json Map<String, String> preferences:
AbstractMetamodel<User, String, Map<String, String>> preferences

orm.entity(User.class).select()
.where(User_.preferences, EQUALS, "{\"theme\":\"dark\"}") // compares the stored text
.getResultList();

Map<String, String> value = User_.preferences.getValue(user); // returns the record's value

The column is a single column, not the fields of the object it holds, so a path does not navigate into it: use User_.preferences, not User_.preferences.theme.

Complex Types​

JSON columns are not limited to maps and primitive collections. You can store structured domain objects directly, preserving their full type hierarchy during serialization and deserialization. This is useful when the nested object has a well-defined shape but does not need its own database table.

When using kotlinx.serialization, annotate the nested type with @Serializable. Jackson discovers types automatically through reflection, so no additional annotation is needed.

@Serializable // For kotlinx.serialization
data class Address(
val street: String,
val city: String,
val postalCode: String
)

data class User(
@PK val id: Int = 0,
val email: String,
@Json val address: Address
) : Entity<Int>

JSON Aggregation​

JSON aggregation solves the problem of loading one-to-many or many-to-many relationships in a single query. Instead of issuing separate queries or relying on lazy loading, you can use SQL aggregation functions like JSON_ARRAYAGG to collect related rows into a JSON array within the main query result. Storm then deserializes that array back into a typed collection on the result object.

This approach eliminates N+1 query problems for relationship loading at the cost of shifting serialization work to both the database and the application layer. It works best when the aggregated collection is moderate in size (see the performance section below).

The example below reads every user with the names of their roles. It joins the UserRole link table and the Role table, aggregates the role names per user into a JSON array, and groups by the user.

data class RolesByUser(
val user: User,
@Json val roles: List<String>
)

interface UserRepository : EntityRepository<User, Int> {

fun getUserRoles(): List<RolesByUser> =
select(RolesByUser::class) { "${User::class}, JSON_ARRAYAGG(${Role::class}.name) AS roles" }
.innerJoin<UserRole>().on<User>()
.innerJoin<Role>().on<UserRole>()
.groupBy(User_.id)
.resultList
}

To aggregate whole objects rather than single values, build each element with JSON_OBJECT inside JSON_ARRAYAGG, one key per property, and declare the field as a list of that type, such as @Json val roles: List<Role>. JSON_OBJECTAGG builds a single object of key/value pairs, which fits a Map field, not a list.

Deserialization Options​

@Json takes two attributes that make deserialization stricter. Both default to false.

AttributeEffect
failOnUnknownFail when the JSON holds a property the target type does not declare. With the default, unknown properties are ignored.
failOnMissingWith Jackson, fail when a property the constructor requires is missing from the JSON. With kotlinx.serialization, fail on a null or unknown value for a property that has a default, rather than falling back to the default.
data class User(
@PK val id: Int = 0,
val email: String,
@Json(failOnUnknown = true) val address: Address
) : Entity<Int>

Database Support​

JSON storage works differently across databases:

DatabaseJSON TypeNotes
PostgreSQLJSONBBinary format, indexable
MySQLJSONNative JSON type
MariaDBJSONAlias for LONGTEXT with validation
OracleJSONNative JSON (21c+)
MS SQL ServerNVARCHAR(MAX)Stored as text
H2CLOBStored as text

Use Cases​

JSON columns are most valuable when relational normalization would add complexity without proportional benefit. The following patterns illustrate the three main scenarios where JSON storage is the right choice.

Flexible Schema​

When different rows need different sets of attributes, a JSON column avoids the overhead of schema migrations and sparse nullable columns. This is common in product catalogs, configuration storage, and user-defined fields.

data class Product(
@PK val id: Int = 0,
val name: String,
@Json val attributes: Map<String, Any> // Size, color, weight, etc.
) : Entity<Int>

Denormalized Data​

Storing a snapshot of related data directly in the parent row avoids joins at read time and preserves the exact state at the moment of creation. This is useful for data that should not change retroactively, such as a shipping address on an order or the line items at the time of purchase.

data class Order(
@PK val id: Int = 0,
val orderDate: LocalDate,
@Json val shippingAddress: Address, // Snapshot at order time
@Json val items: List<OrderItem> // Denormalized for fast access
) : Entity<Int>

Aggregation Results​

Fetch one-to-many or many-to-many relationships in a single query using JSON aggregation. This is the primary alternative to issuing multiple queries or using lazy loading. The trade-off is that the aggregated data arrives as a serialized blob rather than discrete rows, so it works best when the client consumes the collection as a whole rather than filtering or paging within it.

Tips​

  1. Use for truly dynamic data. Don't use JSON to avoid proper schema design.
  2. Consider query patterns. JSON columns are harder to filter and index than normalized columns.
  3. Size limits. Be aware of column size limits for large JSON documents.

JSON Aggregation Performance​

JSON aggregation (JSON_ARRAYAGG, JSON_OBJECTAGG) is suitable for mappings with a moderate size. For larger datasets or extensive mappings, split queries into separate parts to avoid:

  • Memory pressure from large JSON documents
  • Slow serialization/deserialization

When to Split Queries​

Use JSON aggregation when:

  • The aggregated collection typically has < 100 items
  • The JSON payload is under ~1MB
  • The data is read-heavy and benefits from single-query loading

Split into separate queries when:

  • Collections can grow unbounded (e.g., all orders for a customer)
  • You need pagination on the related data
  • The aggregated data is rarely accessed

Example: Splitting Large Relationships​

Instead of aggregating all roles per user:

// Might be slow for users with many roles
data class RolesByUser(val user: User, @Json val roles: List<Role>)

Read the role links in a query of their own, and let Storm group them by user:

// One query for the role links of the users you hold; each UserRole carries its role
val rolesByUser: Map<User, List<UserRole>> = orm.entity(UserRole::class)
.select()
.where(UserRole_.user inList users)
.resultGroupedBy(UserRole_.user)

The grouping happens while Storm hydrates the rows, so the SQL is an ordinary select without a GROUP BY and no JSON is built or parsed. Because users can be a single page, the roles query pages along with it, which gives you control over pagination, caching, and memory usage. See Grouped Results.


See Also​