How to map polymorphic JSON objects with JPA and Hibernate
Are you struggling with performance issues in your Spring, Jakarta EE, or Java EE application?
What if there were a tool that could automatically detect what caused performance issues in your JPA and Hibernate data access layer?
Wouldn’t it be awesome to have such a tool to watch your application and prevent performance issues during development, long before they affect production systems?
Well, Hypersistence Optimizer is that tool! And it works with Spring Boot, Spring Framework, Jakarta EE, Java EE, Quarkus, Micronaut, or Play Framework.
So, rather than fixing performance issues in your production system on a Saturday night, you are better off using Hypersistence Optimizer to help you prevent those issues so that you can spend your time on the things that you love!
Introduction
In this article, I’m going to show you how you can map polymorphic JSON objects when using JPA and Hibernate.
Since Hibernate doesn’t support JSON natively, I’m going to use the Hypersistence Utils library to achieve this goal.
Polymorphic Types
Let’s assume we have the following DiscountCoupon
class hierarchy:
The DiscountCoupon
is the base class of the AmountDiscountCoupon
and PercentageDiscountCoupon
concrete classes, which define two specific ways of discounting the price of a given Book
entity.
The Book
entity is mapped as follows:
@Entity(name = "Book") @Table(name = "book") public class Book { @Id @GeneratedValue private Long id; @NaturalId @Column(length = 15) private String isbn; @Column(columnDefinition = "jsonb") private List<DiscountCoupon> coupons = new ArrayList<>(); }
Note that we want to map the List
of coupons to a JSON column in the database, and for this reason, we need a custom type that can handle the polymorphic types.
The default JsonType
works just fine with concrete classes, but when using a generic List
, the actual type is lost unless we pass it to the database at write time.
Mapping polymorphic JSON objects with Jackson DefaultTyping and Hibernate
One solution is to define a JsonType
that allows us to handle class types that don’t have an explicit concrete type, as it’s the case of abstract classes or interfaces.
In our case, the DiscountCoupon
is an abstract class, hence it cannot be instantiated by Jackson, so we need to know the exact class type of the DiscountCoupon
object reference that we need to instantiate when loading the JSON column from the database.
And for this reason, we can use the following custom JsonType
:
ObjectMapper objectMapper = new ObjectMapperWrapper().getObjectMapper(); properties.put( "hibernate.type_contributors", (TypeContributorList) () -> Collections.singletonList( (typeContributions, serviceRegistry) -> typeContributions.contributeType( new JsonType( objectMapper.activateDefaultTypingAsProperty( objectMapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.OBJECT_AND_NON_CONCRETE, "type" ), ArrayList.class ) { @Override public String getName() { return "json-polymorphic-list"; } } ) ) );
The json-polymorphic-list
customizes the generic JsonType
and provides a custom Jackson ObjectMapper
that uses the DefaultTyping.OBJECT_AND_NON_CONCRETE
strategy.
With the json-polymorphic-list
registered, we just have to provide it to the coupons
property:
@Type(type = "json-polymorphic-list") @Column(columnDefinition = "jsonb") private List<DiscountCoupon> coupons = new ArrayList<>();
Now, when persisting a Book
entity:
entityManager.persist( new Book() .setIsbn("978-9730228236") .addCoupon( new AmountDiscountCoupon("PPP") .setAmount(new BigDecimal("4.99")) ) .addCoupon( new PercentageDiscountCoupon("Black Friday") .setPercentage(BigDecimal.valueOf(0.02)) ) );
For more details about how you can customize the Jackson
ObjectMapper
that the Hypersistence Utils project uses, check out this article as well.
Hibernate generates the following SQL INSERT statements:
INSERT INTO book ( coupons, isbn, id ) VALUES ( [ { "type":"com.vladmihalcea.hibernate.type.json.polymorphic.AmountDiscountCoupon", "name":"PPP", "amount":4.99 }, { "type":"com.vladmihalcea.hibernate.type.json.polymorphic.PercentageDiscountCoupon", "name":"Black Friday", "percentage":0.02 } ], 978-9730228236, 1 )
Notice that Jackson inserted the type
property into the DiscountCoupon
JSON objects. The type
attribute will be used by Jackson when fetching the Book
entity since the underlying JSON object needs to be populated to the associated DiscountCoupon
subclass type.
And, when loading the Book
entity, we can see it loads the DiscountCoupon
objects properly:
Book book = entityManager.unwrap(Session.class) .bySimpleNaturalId(Book.class) .load("978-9730228236"); Map<String, DiscountCoupon> topics = book.getCoupons() .stream() .collect( Collectors.toMap( DiscountCoupon::getName, Function.identity() ) ); assertEquals(2, topics.size()); AmountDiscountCoupon amountDiscountCoupon = (AmountDiscountCoupon) topics.get("PPP"); assertEquals( new BigDecimal("4.99"), amountDiscountCoupon.getAmount() ); PercentageDiscountCoupon percentageDiscountCoupon = (PercentageDiscountCoupon) topics.get("Black Friday"); assertEquals( BigDecimal.valueOf(0.02), percentageDiscountCoupon.getPercentage() );
Mapping polymorphic JSON objects with Jackson JsonTypeInfo
Another approach is using the Jackson @JsonTypeInfo
to define the discriminator property that Kacson can use when reconstructing the Java object from its underlying JSON value.
For that, we need to define a getType
property in DiscountCoupon
and provide the mapping between the type
property values and the associated DiscountCoupon
classes via the @JsonSubTypes
annotation:
@JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type" ) @JsonSubTypes({ @JsonSubTypes.Type( name = "discount.coupon.amount", value = AmountDiscountCoupon.class ), @JsonSubTypes.Type( name = "discount.coupon.percentage", value = PercentageDiscountCoupon.class ), }) 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; } @JsonTypeInfo(use = JsonTypeInfo.Id.NAME) public abstract String getType(); @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof DiscountCoupon)) return false; DiscountCoupon that = (DiscountCoupon) o; return Objects.equals(getName(), that.getName()); } @Override public int hashCode() { return Objects.hash(getName()); } }
The equals
and hashCode
methods are needed by the Hibernate dirty checking mechanism to find out when you’re modifying the coupons and trigger an UPDATE statement.
The AmountDiscountCoupon
implements the getType
method and defines the same discriminator value that the DiscountCoupon
mapped using the @JsonSubTypes.Type
annotation.
public class AmountDiscountCoupon extends DiscountCoupon { public static final String DISCRIMINATOR = "discount.coupon.amount"; 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 String getType() { return DISCRIMINATOR; } }
The PercentageDiscountCoupon
also implements the getType
method and defines the same discriminator value that was used by the associated @JsonSubTypes.Type
annotation in the DiscountCoupon
base class:
public class PercentageDiscountCoupon extends DiscountCoupon { public static final String DISCRIMINATOR = "discount.coupon.percentage"; 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 String getType() { return DISCRIMINATOR; } }
Now, the Book
entity can use the generic JsonType
since the DiscountCoupun
Java objects can be instantiated by Jackson using the available @JsonTypeInfo
mapping.
For Hibernate 6, the mapping will look as follows:
@Entity(name = "Book") @Table(name = "book") public class Book { @Id @GeneratedValue private Long id; @NaturalId @Column(length = 15) private String isbn; @Type(JsonType.class) @Column(columnDefinition = "jsonb") private List<DiscountCoupon> coupons = new ArrayList<>(); }
And for Hibernate 5, like this:
@Entity(name = "Book") @Table(name = "book") @TypeDef(name = "json", typeClass = JsonType.class) public class Book { @Id @GeneratedValue private Long id; @NaturalId @Column(length = 15) private String isbn; @Type(type = "json") @Column(columnDefinition = "jsonb") private List<DiscountCoupon> coupons = new ArrayList<>(); }
And, when persisting the same Book
entity, Hibernate is going to generate the following SQL INSERT statement:
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" } ], 978-9730228236, 1 )
Cool, right?
If you enjoyed this article, I bet you are going to love my Book and Video Courses as well.
Conclusion
Mapping polymorphic JSON objects are very easy with the Hypersistence Utils project. Because you can customize the Jackson ObjectMapper
any way you want, you can address a great variety of use cases using this approach.
