Quarkus Reflection-Free Jackson Serializers: Migrate with Contract Tests
Use a small catalog API to enable reflection-free Jackson, keep tricky JSON payloads honest with contract tests, and benchmark the change without guessing.
Reflection-based JSON serialization is the kind of cost I can ignore right up to the moment I care about startup, native images, or a REST path that actually gets hit. Then it is suddenly everywhere: field introspection on every request, extra GraalVM reachability configuration, and one more place where JVM and native behavior can drift apart.
Quarkus has been chipping away at that problem with build-time metaprogramming: generated StdSerializer implementations that write JSON without poking through DTO fields via reflection at runtime. Mario Fusco walked through the mechanics and the ~12% throughput lift on a synthetic benchmark in the metaprogramming blog post. For a long time, the feature stayed opt-in behind quarkus.rest.jackson.optimization.enable-reflection-free-serializers=true.
In April 2026 the team announced planned default enablement in Quarkus 3.35. That default did not flip in 3.35 after all. Community testing found edge cases, and the 3.35 release notes moved the change to 3.36. For now, the safe mental model is still opt-in migration plus contract tests, not “flip a version and hope.”
We make that migration concrete on a small catalog API: baseline Jackson, reflection-free serializers on a profile, JSON regressions for generics and polymorphism, and a repeatable benchmark harness.
The sample uses Quarkus 3.35.2. Re-check the REST guide after upgrades because serializer coverage is moving quickly.
What we build
We end up with CatalogAPI, a product catalog service that:
exposes CRUD on
/productsbacked by PostgreSQL (Dev Services in dev/test);returns record DTOs, a generic
Page<T>envelope, and polymorphic catalog payloads;serializes a
Moneyvalue type through a custom Jackson serializer registered withObjectMapperCustomizer;runs the same JSON contract tests under baseline and
reflection-freeprofiles;includes a script to compare cold startup and throughput between those profiles.
What you need
I assume you already write Jakarta REST resources and have shipped Jackson DTOs before. This is a migration tutorial, not a Jackson primer.
JDK 21
Quarkus CLI or Maven
Docker or Podman if you want native image builds with containerized GraalVM
Basic Jackson annotations (
@JsonSubTypes, custom serializers)About 45 minutes
Project setup
Create the project:
quarkus create app com.catalogapi:catalogapi-reflection-free-jackson \
--extension='quarkus-rest-jackson,hibernate-orm-panache,jdbc-postgresql,smallrye-openapi' \
--java=21 \
--no-code
We only need four extensions here:
quarkus-rest-jackson— REST endpoints and the Jackson stack we are migratinghibernate-orm-panacheandjdbc-postgresql— small realistic persistence layersmallrye-openapi— generated OpenAPI so the service looks like an internal API, not a toy benchmark
Use package com.catalogapi for application code and com.catalogapi.json for DTOs.
Catalog model and CRUD
Product entity
package com.catalogapi;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
@Entity
public class Product extends PanacheEntity {
@Column(nullable = false, unique = true)
public String sku;
@Column(nullable = false)
public String name;
@Column(nullable = false)
public int priceCents;
@Column(nullable = false)
public String category;
}
Seed data
Create src/main/resources/import.sql:
INSERT INTO Product (id, sku, name, priceCents, category) VALUES (nextval('product_SEQ'), 'SKU-001', 'Mechanical Keyboard', 12999, 'peripherals');
INSERT INTO Product (id, sku, name, priceCents, category) VALUES (nextval('product_SEQ'), 'SKU-002', 'USB-C Hub', 4999, 'peripherals');
INSERT INTO Product (id, sku, name, priceCents, category) VALUES (nextval('product_SEQ'), 'SKU-003', '27-inch Monitor', 34999, 'displays');
INSERT INTO Product (id, sku, name, priceCents, category) VALUES (nextval('product_SEQ'), 'SKU-004', 'Desk Bundle', 52998, 'bundles');
Use nextval('product_SEQ') so Hibernate’s sequence stays aligned with fixed seed rows. If you only insert literal id values, the first persist() after startup can collide with the primary key.
Dev configuration
In application.properties:
%dev.quarkus.datasource.db-kind=postgresql
%dev.quarkus.hibernate-orm.database.generation=drop-and-create
%dev.quarkus.hibernate-orm.sql-load-script=import.sql
%test.quarkus.datasource.db-kind=h2
%test.quarkus.datasource.jdbc.url=jdbc:h2:mem:catalogtest;DB_CLOSE_DELAY=-1;MODE=PostgreSQL
%test.quarkus.hibernate-orm.schema-management.strategy=drop-and-create
%test.quarkus.hibernate-orm.sql-load-script=import.sql
Dev mode uses PostgreSQL Dev Services. Tests use in-memory H2, so ./mvnw test stays Docker-free on the machine running CI.
Product resource
package com.catalogapi;
import java.util.List;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import com.catalogapi.json.ProductInput;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/products")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Products")
public class ProductResource {
@GET
@Operation(summary = "List all products")
public List<Product> list() {
return Product.listAll();
}
@GET
@Path("/{id}")
@Operation(summary = "Get one product by id")
public Product get(@PathParam("id") long id) {
return Product.<Product>findByIdOptional(id)
.orElseThrow(NotFoundException::new);
}
@POST
@Transactional
@Operation(summary = "Create a product")
public Response create(ProductInput input) {
Product product = new Product();
product.sku = input.sku();
product.name = input.name();
product.priceCents = input.priceCents();
product.category = input.category();
product.persist();
return Response.status(Response.Status.CREATED).entity(product).build();
}
@PUT
@Path("/{id}")
@Transactional
@Operation(summary = "Update a product")
public Product update(@PathParam("id") long id, ProductInput input) {
Product product = Product.<Product>findByIdOptional(id)
.orElseThrow(NotFoundException::new);
product.sku = input.sku();
product.name = input.name();
product.priceCents = input.priceCents();
product.category = input.category();
return product;
}
}
ProductInput is just a small record:
package com.catalogapi.json;
public record ProductInput(String sku, String name, int priceCents, String category) {
}
Prove CRUD
Start dev mode:
./mvnw quarkus:dev
List products:
curl -s http://localhost:8080/products | jq .
You should see the four seeded products. Fetch one next:
curl -s http://localhost:8080/products/1 | jq .
Expected shape:
{
"id": 1,
"sku": "SKU-001",
"name": "Mechanical Keyboard",
"priceCents": 12999,
"category": "peripherals"
}
JSON edge cases worth testing before you flip the switch
Real services rarely stop at flat entities. CatalogAPI adds four patterns that tend to find serializer gaps quickly.
Record summaries with custom Money serialization
package com.catalogapi.json;
public record Money(String currency, long amountMinor) {
}
package com.catalogapi.json;
public record ProductSummary(long id, String sku, String name, Money price) {
}
package com.catalogapi.json;
import java.io.IOException;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
public class MoneySerializer extends StdSerializer<Money> {
public MoneySerializer() {
super(Money.class);
}
@Override
public void serialize(Money value, JsonGenerator generator, SerializerProvider serializers) throws IOException {
generator.writeStartObject();
generator.writeStringField("currency", value.currency());
generator.writeNumberField("amountMinor", value.amountMinor());
generator.writeStringField("display", value.currency() + " " + formatMinor(value.amountMinor()));
generator.writeEndObject();
}
private static String formatMinor(long amountMinor) {
long major = amountMinor / 100;
long minor = Math.abs(amountMinor % 100);
return major + "." + (minor < 10 ? "0" : "") + minor;
}
}
Register the module at startup:
package com.catalogapi.jackson;
import com.catalogapi.json.Money;
import com.catalogapi.json.MoneySerializer;
import com.fasterxml.jackson.databind.module.SimpleModule;
import io.quarkus.jackson.ObjectMapperCustomizer;
import jakarta.inject.Singleton;
@Singleton
public class CatalogJacksonCustomizer implements ObjectMapperCustomizer {
@Override
public void customize(com.fasterxml.jackson.databind.ObjectMapper mapper) {
SimpleModule module = new SimpleModule();
module.addSerializer(Money.class, new MoneySerializer());
mapper.registerModule(module);
}
}
I keep the entity-to-summary mapping in a tiny helper:
package com.catalogapi;
import com.catalogapi.json.Money;
import com.catalogapi.json.ProductSummary;
final class ProductMapper {
private ProductMapper() {
}
static ProductSummary toSummary(Product product) {
return new ProductSummary(
product.id,
product.sku,
product.name,
new Money("USD", product.priceCents));
}
}
Add toMoney next to toSummary in ProductMapper, then add two endpoints on ProductResource (imports for Page and ProductSummary are omitted below only where they are already on the class):
@GET
@Path("/summaries")
@Operation(summary = "List product summaries as records with custom Money serialization")
public List<ProductSummary> summaries() {
return Product.<Product>listAll().stream()
.map(ProductMapper::toSummary)
.toList();
}
@GET
@Path("/page")
@Operation(summary = "Paged product summaries in a generic envelope")
public Page<ProductSummary> page() {
List<ProductSummary> items = Product.<Product>listAll().stream()
.map(ProductMapper::toSummary)
.toList();
return new Page<>(items, items.size());
}
With Page defined as:
package com.catalogapi.json;
import java.util.List;
public record Page<T>(List<T> items, int total) {
}
Checkpoint:
curl -s http://localhost:8080/products/summaries | jq '.[0].price'
Expected:
{
"currency": "USD",
"amountMinor": 12999,
"display": "USD 129.99"
}
Polymorphic catalog payloads
package com.catalogapi.json;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = ProductView.class, name = "product"),
@JsonSubTypes.Type(value = BundleView.class, name = "bundle")
})
public sealed interface CatalogPayload permits ProductView, BundleView {
}
package com.catalogapi.json;
public record ProductView(long id, String sku, Money price) implements CatalogPayload {
}
package com.catalogapi.json;
import java.util.List;
public record BundleView(String name, List<String> skuList, Money totalPrice) implements CatalogPayload {
}
package com.catalogapi;
import java.util.List;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import com.catalogapi.json.BundleView;
import com.catalogapi.json.CatalogPayload;
import com.catalogapi.json.ProductView;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/catalog/payloads")
@Produces(MediaType.APPLICATION_JSON)
@Tag(name = "Catalog payloads")
public class CatalogPayloadResource {
@GET
@Path("/demo")
@Operation(summary = "Polymorphic catalog payloads for contract testing")
public List<CatalogPayload> demo() {
Product keyboard = Product.find("sku", "SKU-001").firstResult();
Product hub = Product.find("sku", "SKU-002").firstResult();
Product bundle = Product.find("sku", "SKU-004").firstResult();
return List.of(
new ProductView(keyboard.id, keyboard.sku, ProductMapper.toMoney(keyboard)),
new ProductView(hub.id, hub.sku, ProductMapper.toMoney(hub)),
new BundleView(
bundle.name,
List.of("SKU-001", "SKU-002"),
ProductMapper.toMoney(bundle)));
}
}
Add ProductMapper.toMoney alongside toSummary.
Checkpoint:
curl -s http://localhost:8080/catalog/payloads/demo | jq '.[2]'
You should see "type": "bundle" and a totalPrice.display field.
Lock the baseline JSON contract
Before enabling reflection-free serializers, capture what “correct” looks like in tests. REST Assured plus Hamcrest json-path is boring in the best way: it keeps regressions out of eyeball diffs.
Shared assertions live in JsonContractAssertions:
package com.catalogapi;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
final class JsonContractAssertions {
private JsonContractAssertions() {
}
static void assertSummariesContract() {
given().when()
.get("/products/summaries")
.then()
.statusCode(200)
.body("$", hasSize(4))
.body("sku", hasItem("SKU-001"))
.body("find { it.sku == 'SKU-001' }.price.currency", equalTo("USD"))
.body("find { it.sku == 'SKU-001' }.price.amountMinor", equalTo(12999))
.body("find { it.sku == 'SKU-001' }.price.display", equalTo("USD 129.99"));
}
static void assertPageContract() {
given().when()
.get("/products/page")
.then()
.statusCode(200)
.body("total", equalTo(4))
.body("items", hasSize(4))
.body("items[0].id", is(1))
.body("items[0].price.display", equalTo("USD 129.99"));
}
static void assertPolymorphicContract() {
given().when()
.get("/catalog/payloads/demo")
.then()
.statusCode(200)
.body("$", hasSize(3))
.body("[0].type", equalTo("product"))
.body("[0].sku", equalTo("SKU-001"))
.body("[2].type", equalTo("bundle"))
.body("[2].name", equalTo("Desk Bundle"))
.body("[2].skuList", hasSize(2))
.body("[2].totalPrice.display", equalTo("USD 529.98"));
}
}
Baseline tests:
package com.catalogapi;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
class JsonContractBaselineTest {
@Test
void summariesMatchBaselineContract() {
JsonContractAssertions.assertSummariesContract();
}
@Test
void pageMatchesBaselineContract() {
JsonContractAssertions.assertPageContract();
}
@Test
void polymorphicPayloadMatchesBaselineContract() {
JsonContractAssertions.assertPolymorphicContract();
}
}
Run:
./mvnw test
All tests should pass on the default profile.
Enable reflection-free serializers
Add a dedicated profile in application.properties:
%reflection-free.quarkus.rest.jackson.optimization.enable-reflection-free-serializers=true
Run dev mode with the profile:
./mvnw quarkus:dev -Dquarkus.profile=reflection-free
Re-run the same curl checks. On CatalogAPI the payloads matched baseline in my runs, but your service may not be that polite. That is why the tests exist.
Run the same tests under reflection-free
ReflectionFreeProfile activates the profile in tests:
package com.catalogapi;
import java.util.Map;
import io.quarkus.test.junit.QuarkusTestProfile;
public class ReflectionFreeProfile implements QuarkusTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
return Map.of(
"quarkus.rest.jackson.optimization.enable-reflection-free-serializers", "true");
}
}
Keep the active profile as test (the default for @QuarkusTest). Only override the serializer flag. If you replace the whole profile with reflection-free, it is easy to drop the %test datasource settings and start debugging the wrong failure.
package com.catalogapi;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.TestProfile;
@QuarkusTest
@TestProfile(ReflectionFreeProfile.class)
class JsonContractReflectionFreeTest {
@Test
void summariesMatchReflectionFreeContract() {
JsonContractAssertions.assertSummariesContract();
}
@Test
void pageMatchesReflectionFreeContract() {
JsonContractAssertions.assertPageContract();
}
@Test
void polymorphicPayloadMatchesReflectionFreeContract() {
JsonContractAssertions.assertPolymorphicContract();
}
}
Run ./mvnw test again. If anything fails here but passed in baseline, you have a migration bug — not a “maybe” problem.
What the build actually generates (and what it skips)
Watch the build log with reflection-free enabled. For CatalogAPI you will see generated serializers for DTOs like ProductSummary and Page, while Product itself is skipped because JPA’s @Column is not supported by the generator yet:
Skipping generation of reflection-free Jackson serializer for class com.catalogapi.Product
because it contains the unsupported Jackson annotation jakarta.persistence.Column
That detail matters in production: returning Panache entities directly from REST still goes through reflection-based Jackson for this entity. Migration work should focus on DTOs you control, or you should accept mixed mode until coverage catches up. The REST guide documents the optimization flag; the metaprogramming post explains the Gizmo-generated StdSerializer classes registered on the shared ObjectMapper.
Custom serializers registered through ObjectMapperCustomizer remain part of the contract — our MoneySerializer is exactly the kind of module you should re-test after flipping the flag.
Benchmarks without fooling yourself
The module includes scripts/compare-json-serialization.sh. It packages a JVM runner with an in-memory benchmark profile (H2, seeded data), so you do not need PostgreSQL running for measurements:
%benchmark.quarkus.datasource.db-kind=h2
%benchmark.quarkus.datasource.jdbc.url=jdbc:h2:mem:catalog;DB_CLOSE_DELAY=-1;MODE=PostgreSQL
%benchmark.quarkus.hibernate-orm.schema-management.strategy=drop-and-create
%benchmark.quarkus.hibernate-orm.sql-load-script=import.sql
Run baseline and reflection-free back to back:
./scripts/compare-json-serialization.sh
./scripts/compare-json-serialization.sh reflection-free
On my machine (Apple Silicon laptop, local loopback) a recent run looked like:
Baseline — cold ready in ~1250 ms; Quarkus log reported
started in 0.855safter the process was already warming.Reflection-free — cold ready in a similar band; Quarkus log
started in 0.876s.
Throughput on /products/summaries with hey was effectively identical between profiles for this small payload. That is still a useful result: do not expect fireworks on every endpoint. The gains concentrate on serialization-heavy paths and native-image reachability, not on a four-row catalog listing.
Treat any numbers as relative signals, not scripture. Match JDK, CPU power profile, dataset size, and warmup when you compare before and after in your own environment.
Native image proof
If native image is part of your deployment story, include it in the migration proof:
./mvnw package -Dnative -Dquarkus.native.container-build=true -Dquarkus.profile=benchmark,reflection-free
Compare runner size under target/*-runner and cold-start time the same way you would for any native rollout. Reflection-free serializers reduce Jackson’s reflection footprint; they do not replace native configuration for libraries you still register reflectively.
Production migration checklist
When you roll this out on a real service, I would keep the checklist short:
Enable in staging first with
quarkus.rest.jackson.optimization.enable-reflection-free-serializers=trueon a canary profile.Run JSON contract tests on every response type you care about — records, generics, polymorphism, custom serializers, views.
Watch build logs for “Skipping generation” lines; map those classes to DTOs or accept reflection fallback.
Keep CI native builds if you ship native images — serializer changes show up in reachability and startup, not only in unit tests.
Know the escape hatch — set the property back to
falseif you hit an unsupported Jackson feature. Fighting with@RegisterForReflectionon DTOs is usually the wrong lever.Track Quarkus 3.36 — default enablement is coming; tests you write now are the safety net when your platform BOM moves.
Closing
Reflection-free Jackson serializers are Quarkus doing what it usually does best: push work to build time, trim runtime reflection, and make native image less annoying. They are not a silent drop-in for every @Column-annotated entity or every exotic Jackson module.
CatalogAPI shows a practical migration path: baseline behavior, explicit profile, shared contract tests, and benchmarks that stay honest about scope. Run the tests, read the build log, measure the endpoints that actually matter in your service, and keep the config escape hatch one property away.
Source for the full sample lives in the catalogapi-reflection-free-jackson repository.


