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
@Serializabletype, a type registered in a serializers module, or a collection orRefof one. - Jackson converts the remaining
@Jsonfields, 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.
- Kotlin
- Java
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.
record User(@PK Integer id,
String email,
@Json Map<String, String> preferences
) implements Entity<Integer> {}
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.
- Kotlin
- Java
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>
Structured domain objects work the same way in Java. Jackson handles serialization automatically for Java records without additional annotations.
record Address(String street,
String city,
String postalCode) {}
record User(@PK Integer id,
String email,
@Json Address address
) implements Entity<Integer> {}
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.
- Kotlin
- Java
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
}
The same aggregation pattern applies in Java using string templates. JSON_ARRAYAGG collects the role names into a JSON array that Storm deserializes into the annotated @Json field.
record RolesByUser(User user, @Json List<String> roles) {}
interface UserRepository extends EntityRepository<User, Integer> {
default List<RolesByUser> getUserRoles() {
return select(RolesByUser.class, RAW."\{User.class}, JSON_ARRAYAGG(\{Role.class}.name) AS roles")
.innerJoin(UserRole.class).on(User.class)
.innerJoin(Role.class).on(UserRole.class)
.groupBy(User_.id)
.getResultList();
}
}
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.
| Attribute | Effect |
|---|---|
failOnUnknown | Fail when the JSON holds a property the target type does not declare. With the default, unknown properties are ignored. |
failOnMissing | With 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:
| Database | JSON Type | Notes |
|---|---|---|
| PostgreSQL | JSONB | Binary format, indexable |
| MySQL | JSON | Native JSON type |
| MariaDB | JSON | Alias for LONGTEXT with validation |
| Oracle | JSON | Native JSON (21c+) |
| MS SQL Server | NVARCHAR(MAX) | Stored as text |
| H2 | CLOB | Stored 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
- Use for truly dynamic data. Don't use JSON to avoid proper schema design.
- Consider query patterns. JSON columns are harder to filter and index than normalized columns.
- 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
- Queries - aggregation with JSON
- Dialects - database-specific JSON support
- Entities -
@Jsonannotation - Entity Serialization - serializing entities with
Reffields to JSON (for REST APIs, not database columns)