Jackson Deep Dive for Quarkus Developers: From Circular References to Custom Serializers
A practical, step-by-step guide to mastering JSON handling with Quarkus — solve real-world serialization issues in your REST APIs.
JSON is the language of modern microservices. Every REST API you build with Quarkus relies on one critical piece: Jackson’s ObjectMapper. It decides how your data becomes JSON and what fields appear, how dates are formatted, and what happens when clients send unknown fields.
If you’ve ever had a deserialization error in production or inconsistent field naming across services, this tutorial is for you.
We’ll configure Jackson the Quarkus way, step by step.
What You’ll Build
You’ll create a small Quarkus REST API for managing products, with a custom global ObjectMapper configuration that:
Formats JSON neatly in development
Ignores unknown fields gracefully
Serializes date/time types as ISO-8601 strings
Excludes
nullfieldsUses
snake_casenaming conventions for consistency
You’ll test each behavior and understand when and why to use it. And yes, I did include a test this time!
Take a look and grab the project from my repository if you like.
Prerequisites
Java 21 or later
Quarkus CLI (get a quick refresher here!)
A basic understanding of REST APIs and Quarkus
Bootstrap the Project
Create a new Quarkus app with the REST and Jackson extensions:
quarkus create app com.example:jackson-deep-dive \
--extension=rest-jackson
cd jackson-deep-diveThis gives you a REST endpoint scaffolded at /hello and brings in quarkus-rest-jackson, which uses Jackson for JSON serialization.
Create a Simple Domain Model
We’ll work with a simple Product entity to demonstrate JSON serialization features.
package com.example.model;
import java.time.LocalDateTime;
public class Product {
private Long id;
private String name;
private Double price;
private String description;
private LocalDateTime createdAt;
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Double getPrice() { return price; }
public void setPrice(Double price) { this.price = price; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
Expose a REST Endpoint
We’ll return our Product as JSON so we can observe how Jackson serializes it. Rename GreetingResource to ProductResource and replace the content with:
package com.example;
import java.time.LocalDateTime;
import com.example.model.Product;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
@Path(”/products”)
public class ProductResource {
@GET
public Product getProduct() {
Product product = new Product();
product.setId(1L);
product.setName(”Laptop”);
product.setPrice(999.99);
product.setDescription(null);
product.setCreatedAt(LocalDateTime.of(2024, 10, 15, 14, 30));
return product;
}
}Run the app:
quarkus devVisit http://localhost:8080/products.
You’ll see something similar to this:
{”id”:1,”name”:”Laptop”,”price”:999.99,”description”:null,”createdAt”:”2024-10-15T14:30:00”}That’s Jackson’s default behavior:
Compact output
Nulls included
Timestamps as arrays
camelCase property names
We’ll fix all of that next.
Create a Global ObjectMapper Customizer
Create a configuration class in src/main/java/com/example/config/ProductionJacksonConfig.java:
package com.example.config;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.SerializationFeature;
import io.quarkus.jackson.ObjectMapperCustomizer;
import jakarta.inject.Singleton;
@Singleton
public class ProductionJacksonConfig implements ObjectMapperCustomizer {
@Override
public void customize(ObjectMapper mapper) {
// === Serialization Settings ===
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); // Use ISO-8601
mapper.setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL); // Skip nulls
mapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS);
mapper.disable(SerializationFeature.INDENT_OUTPUT); // Compact in prod
// === Deserialization Settings ===
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
mapper.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, false);
mapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true);
// === Naming Strategy ===
mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
}
}Keep Quarkus running and reload your browser:
{”id”:1,”name”:”Laptop”,”price”:999.99,”created_at”:”2024-10-15T14:30:00”}✅ Nulls are gone
✅ ISO-8601 date format
✅ snake_case naming
✅ Compact output
You just applied enterprise-level consistency in one class.
Pretty Printing in Dev Mode
Developers love readable JSON. Let’s enable pretty printing when the app runs in the dev profile.
package com.example.config;
import com.fasterxml.jackson.databind.*;
import io.quarkus.jackson.ObjectMapperCustomizer;
import jakarta.inject.Singleton;
import org.eclipse.microprofile.config.inject.ConfigProperty;
@Singleton
public class EnvironmentAwareJacksonConfig implements ObjectMapperCustomizer {
@ConfigProperty(name = “quarkus.profile”)
String profile;
@Override
public void customize(ObjectMapper mapper) {
if (”dev”.equals(profile)) {
mapper.enable(SerializationFeature.INDENT_OUTPUT);
} else {
mapper.disable(SerializationFeature.INDENT_OUTPUT);
}
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true);
}
}Reload (as you’re still in dev mode) /products.
Now your JSON looks beautifully formatted:
{
“id” : 1,
“name” : “Laptop”,
“price” : 999.99,
“created_at” : “2024-10-15T14:30:00”
}Simulate an Unknown Property
Let’s test lenient deserialization.
Add a POST endpoint to your ProductResource that accepts incoming JSON:
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Product createProduct(Product product) {
return product;
}Send a POST request with an extra field:
curl -X -v POST http://localhost:8080/products \
-H "Content-Type: application/json" \
-d '{"id":2,"name":"Tablet","category":"Electronics"}'Because we enabled FAIL_ON_UNKNOWN_PROPERTIES, you get:
{
“object_name” : “Product”,
“attribute_name” : “category”,
“line” : 1,
“column” : 37
}This error message indicates a JSON deserialization validation error. The response shows:
“object_name”: “Product” - The Java class being deserialized
“attribute_name”: “category” - The field that’s causing the issue
“line”: 1, “column”: 37 - Position in the JSON where the error occurred
Flip the switch in EnvironmentAwareJacksonConfig to false and try again.
Verify Configuration with Tests
Create src/test/java/com/example/JacksonConfigTest.java:
package com.example;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import java.time.LocalDateTime;
import org.junit.jupiter.api.Test;
import com.example.model.Product;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
@QuarkusTest
public class JacksonConfigTest {
@Inject
ObjectMapper mapper;
@Test
public void testDateFormat() throws Exception {
Product product = new Product();
product.setCreatedAt(LocalDateTime.of(2024, 10, 15, 14, 30));
String json = mapper.writeValueAsString(product);
assertTrue(json.contains(”2024-10-15”));
assertFalse(json.contains(”[2024,”));
}
@Test
public void testNullValuesExcluded() throws Exception {
Product product = new Product();
product.setId(1L);
product.setName(”Laptop”);
String json = mapper.writeValueAsString(product);
assertFalse(json.contains(”null”));
}
@Test
public void testUnknownPropertiesIgnored() throws Exception {
String json = “”“
{”id”:1,”name”:”Laptop”,”extra”:”ignored”}
“”“;
Product product = mapper.readValue(json, Product.class);
assertEquals(1L, product.getId());
assertEquals(”Laptop”, product.getName());
assertNull(product.getDescription());
}
}Run:
./mvnw testAll tests should pass.
You’ve verified your configuration works as intended.
Common Pitfalls
1. Multiple Customizers
Quarkus merges all ObjectMapperCustomizer beans. Conflicting settings can cause inconsistent results. Consolidate configuration into one class.
2. Relying on Defaults
Default Jackson behavior can change with upgrades. Pin versions and test your JSON formats.
Where to go next?
To explore more:
Add
@JsonViewto control per-endpoint field visibility.Use
@JsonIgnorePropertiesto skip specific fields.Register custom serializers and deserializers for domain objects.
Integrate schema validation with
JsonSchema.
Each of these features plugs neatly into your global configuration.
Jackson’s ObjectMapper is not just a JSON utility
It’s a contract between your services and clients. Configuring it properly means fewer surprises, safer upgrades, and cleaner payloads.
With a single Quarkus ObjectMapperCustomizer, you control that contract globally.
Consistent JSON. Predictable APIs. Enterprise-grade defaults.



