Polymorphic JSON in Quarkus: Flexible Data Models with Jakarta Data
Learn how to store and query polymorphic objects as JSON using Quarkus, Hibernate ORM, and Jakarta Data — a modern, standards-based approach for evolving enterprise applications.
When you design enterprise applications, it’s tempting to normalize everything into relational tables. But sometimes, flexibility matters more than rigid schemas. Discount rules, configuration data, or pricing policies evolve quickly. That’s where storing polymorphic data structures in JSON becomes practical.
Vlad Mihalcea’s original Hibernate example demonstrated this beautifully with Spring Boot. Let’s rebuild it the Quarkus way, using Jakarta Data, JAX-RS, and Hibernate ORM.
Also a big “thank you!” to Christian Beikov who nudged me into the right direction with this!
Why Use Quarkus and Jakarta Data
Migrating to Quarkus is not about fashion. It’s about efficiency and standards.
Jakarta Data: a vendor-neutral abstraction for repositories, part of the Jakarta EE ecosystem.
Quarkus: optimized for startup time, memory use, and cloud deployments.
Native Compilation: turn your app into a GraalVM native image without boilerplate.
Modern Java: records, sealed classes, and pattern matching work out of the box.
Lightweight Stack: JAX-RS replaces Spring MVC for REST without heavy reflection.
Project Setup
You need:
Java 21+
Maven 3.9+
PostgreSQL (or Podman for Dev Services)
Quarkus CLI (
brew install quarkusorsdk install quarkus)
Create a new project
quarkus create app org.acme:polymorphic-json:1.0 \
--extension='hibernate-orm,jdbc-postgresql,rest-jackson,rest'
cd polymorphic-jsonThis scaffolds a Quarkus app with Hibernate ORM, Jackson. We do need to add the Jakarta Data dependencies manually to the generated pom.xml:
<dependency>
<groupId>jakarta.data</groupId>
<artifactId>jakarta.data-api</artifactId>
<version>1.0.1</version>
</dependency>
<!-- For some reason, Maven requires the annotation processor as a dependency here instead of in the compiler plugin dependencies -->
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-processor</artifactId>
<scope>provided</scope>
</dependency>We also need to add the Hibernate processor to the Maven compiler plug-in configuration:
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>${compiler-plugin.version}</version>
<configuration>
<parameters>true</parameters>
<annotationProcessors>
<annotationProcessor>org.hibernate.processor.HibernateProcessor</annotationProcessor>
</annotationProcessors>
</configuration>
</plugin>And while we are here, let’s also add the following to the application.properties
quarkus.hibernate-orm.mapping.format.global=ignore
quarkus.hibernate-orm.log.sql=trueThe second configuration shows the sql in the Quarkus log file. The first one is a migration helper that basically helps mitigate migrations from the current Quarkus-preconfigured XML/JSON format mappers.
Let’s also add an resources/import.sql that pre-loads a book into the database:
insert into book (
coupons,
isbn,
id
)
values (
‘[
{
“name”:”PPP”,
“amount”:4.99,
“type”:”discount.coupon.amount”
},
{
“name”:”Black Friday”,
“percentage”:0.02,
“type”:”discount.coupon.percentage”
}
]’::json,
‘978-9730228236’,
1
);
alter sequence book_SEQ restart with 2;The Domain Model
We’ll model books that can contain different types of discount coupons. Each coupon subtype will be stored as JSON inside a single column.
Base Class
package org.acme.model;
import java.io.Serializable;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import jakarta.persistence.DiscriminatorColumn;
import jakarta.persistence.Embeddable;
@Embeddable
@DiscriminatorColumn(name = “type”)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = “type”)
@JsonSubTypes({
@JsonSubTypes.Type(value = AmountDiscountCoupon.class, name = “amount”),
@JsonSubTypes.Type(value = PercentageDiscountCoupon.class, name = “percentage”)
})
public abstract class DiscountCoupon implements Serializable {
private String name;
public DiscountCoupon() {
}
public DiscountCoupon(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}How it works:
@Embeddable: allows embedding into another entity.@JsonTypeInfo+@JsonSubTypes: Jackson uses these to serialize/deserialize the correct subclass.@DiscriminatorColumn: optional but helps Hibernate manage inheritance cleanly.
Subtypes
package org.acme.model;
import java.math.BigDecimal;
import java.util.Objects;
import jakarta.persistence.DiscriminatorValue;
import jakarta.persistence.Embeddable;
@Embeddable
@DiscriminatorValue(”discount.coupon.amount”)
public class AmountDiscountCoupon extends DiscountCoupon {
private BigDecimal amount;
public AmountDiscountCoupon() {
}
public AmountDiscountCoupon(String name) {
super(name);
}
public BigDecimal getAmount() {
return amount;
}
public AmountDiscountCoupon setAmount(BigDecimal amount) {
this.amount = amount;
return this;
}
@Override
public final boolean equals(Object o) {
return o instanceof AmountDiscountCoupon that
&& Objects.equals(amount, that.amount);
}
@Override
public int hashCode() {
return Objects.hashCode(amount);
}
}And the:
package org.acme.model;
import java.math.BigDecimal;
import java.util.Objects;
import jakarta.persistence.DiscriminatorValue;
import jakarta.persistence.Embeddable;
@Embeddable
@DiscriminatorValue(”discount.coupon.percentage”)
public class PercentageDiscountCoupon extends DiscountCoupon {
private BigDecimal percentage;
public PercentageDiscountCoupon() {
}
public PercentageDiscountCoupon(String name) {
super(name);
}
public BigDecimal getPercentage() {
return percentage;
}
public PercentageDiscountCoupon setPercentage(BigDecimal amount) {
this.percentage = amount;
return this;
}
@Override
public final boolean equals(Object o) {
return o instanceof PercentageDiscountCoupon that
&& Objects.equals(percentage, that.percentage);
}
@Override
public int hashCode() {
return Objects.hashCode(percentage);
}
}Each subclass adds its own field. Jackson will serialize them differently based on the type property.
Book Entity
package org.acme.model;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.NaturalId;
import org.hibernate.type.SqlTypes;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity(name = “Book”)
@Table(name = “book”)
public class Book {
@Id
@GeneratedValue
private Long id;
@NaturalId
@Column(length = 15)
private String isbn;
@JdbcTypeCode(SqlTypes.JSON_ARRAY)
private List<DiscountCoupon> coupons = new ArrayList<>();
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getIsbn() {
return isbn;
}
public void setIsbn(String isbn) {
this.isbn = isbn;
}
public List<DiscountCoupon> getCoupons() {
return coupons;
}
public void setCoupons(List<DiscountCoupon> coupons) {
this.coupons = coupons;
}
}The @JdbcTypeCode(SqlTypes.JSON_ARRAY) annotation instructs Hibernate to serialize the list into a JSON array. PostgreSQL’s jsonb handles this natively.
Jakarta Data Repository
Spring Data developers will feel at home, but Jakarta Data is cleaner and standardized.
package org.acme.repository;
import java.util.List;
import org.acme.model.Book;
import jakarta.data.repository.Find;
import jakarta.data.repository.Insert;
import jakarta.data.repository.Repository;
import jakarta.data.repository.Update;
@Repository
public interface BookRepository {
@Find
List<Book> getBooks();
@Find
Book findBookById(long id);
@Find
Book findBookByIsbn(String isbn);
@Insert
Book createBook(Book book);
@Update
void updateBook(Book book);
}Notes:
@Find,@Insert,@Updatemake intent explicit.The same interface can work with Hibernate, EclipseLink, or future Jakarta Data providers.
You don’t need method-name conventions like in Spring Data.
REST API with JAX-RS
Instead of controllers, Quarkus uses resources.
package org.acme.resource;
import java.util.Collections;
import java.util.List;
import org.acme.model.Book;
import org.acme.model.BookDto;
import org.acme.repository.BookRepository;
import org.hibernate.Session;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.QueryParam;
@Path(”/books”)
public class BookResource {
@Inject
BookRepository bookRepository;
@Inject
Session session;
@GET
public List<Book> getBooks(@QueryParam(”isbn”) String isbn) {
return isbn == null
? bookRepository.getBooks()
: Collections.singletonList(bookRepository.findBookByIsbn(isbn));
}
@GET
@Path(”{id}”)
public Book getBook(@PathParam(”id”) long id) {
return bookRepository.findBookById(id);
}
@PUT
@Path(”{id}”)
public void updateBook(@PathParam(”id”) long id, BookDto dto) {
final var book = dto.toEntity();
book.setId(id);
bookRepository.updateBook(book);
}
@POST
public Book addBook(BookDto dto) {
return bookRepository.createBook(dto.toEntity());
}
}Quarkus injects the repository directly. The REST layer stays thin and declarative.
DTO with Java Records
Records are concise and immutable which is perfect for data transfer.
package org.acme.model;
import java.util.List;
public record BookDto(
String isbn,
List<DiscountCoupon> coupons) {
public Book toEntity() {
final var book = new Book();
book.setIsbn(isbn);
book.setCoupons(coupons);
return book;
}
}Try It Out
Start PostgreSQL with Dev Services:
quarkus devList all books:
curl -X GET http://localhost:8080/books | jqThe response looks like:
[
{
“id”: 1,
“isbn”: “978-9730228236”,
“coupons”: [
{
“type”: “amount”,
“name”: “PPP”,
“amount”: 4.99
},
{
“type”: “percentage”,
“name”: “Black Friday”,
“percentage”: 0.02
}
]
}
]When you add a new book with POST:
curl -X POST http://localhost:8080/books \
-H “Content-Type: application/json” \
-d ‘{
“isbn”: “978-0-123456-78”,
“coupons”: [
{
“type”: “amount”,
“name”: “Summer Sale”,
“amount”: 10.00
}
]
}’Verify it:
Find the PostgreSQL container ID:
podman psAnd connect to the container
podman exec -it <CONTAINER_NAME> psql -U quarkus -d quarkusAnd select from the db:
SELECT coupons FROM book;Hibernate persists this like:
[
{
“name”: “PPP”,
“type”: “discount.coupon.amount”,
“amount”: 4.99
},
{
“name”: “Black Friday”,
“type”: “discount.coupon.percentage”,
“percentage”: 0.02
}
]Production Notes
Index JSON columns: use
GINindexes for PostgreSQL JSONB.Native Image: register your Jackson subtypes via
@RegisterForReflection.Evolving Schemas: JSON columns let you extend models without schema changes.
Performance: read/write overhead is minimal for small documents.
Common Pitfalls
Querying JSON: database-specific functions (
jsonb_path_query, etc.) may differ.Polymorphism Mismatch: ensure
typevalues in JSON match@JsonSubTypes.Serialization Failures: Jackson must see all concrete types at build time in native images.
Flexibility meets Structure
When you work in large, evolving systems, flexibility and structure often pull in opposite directions. Storing polymorphic objects as JSON lets you keep that balance. The relational database gives you integrity and transactional guarantees, while JSON gives you adaptability as your business logic changes. With Quarkus and Jakarta Data, this approach becomes elegant rather than messy. You define your model hierarchies in Java, let Hibernate handle the JSON serialization, and expose everything through standards-based APIs. The result is a clean architecture that scales from prototype to production without locking you into a proprietary stack. It’s a practical reminder that modern enterprise development isn’t about choosing between order and freedom, it’s about using the right abstractions to get both.
References
Flexible data doesn’t have to mean chaos. Quarkus and Jakarta Data make polymorphism in JSON clean, type-safe, and production-ready.



