1
0

Jackson 3 series companion code

37 runnable examples covering the eight feature posts on ankurm.com, verified
against Jackson 3.2.1 on Temurin 21.0.5. Every output committed under docs/ was
produced by run-all.sh.

Also documents 11 places where the published snippets do not compile or do not
behave as printed against a real Jackson 3 build - most notably that
writeValueAsString(List<Base>) silently drops the polymorphic type discriminator,
so the post's serialised output cannot be read back.
This commit is contained in:
2026-08-04 23:12:11 +05:30
commit ef05f9024e
94 changed files with 3361 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
package com.ankurm.jackson3.beyond;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.json.JsonMapper;
import java.io.IOException;
import java.io.StringReader;
import java.util.List;
import java.util.stream.Stream;
/**
* BEYOND THE POSTS — the change most likely to reach production unnoticed.
*
* In Jackson 2, JsonProcessingException extended IOException, so `catch (IOException)`
* caught mapping failures. In Jackson 3, JacksonException extends RuntimeException.
*
* There are two cases, and only one of them is safe:
*
* A. The try block contains ONLY Jackson calls. `catch (IOException)` becomes a
* COMPILE ERROR — "exception java.io.IOException is never thrown in body of
* corresponding try statement". The compiler saves you. Good.
*
* B. The try block also contains real I/O — reading a file, a socket, a request
* body. IOException is still thrown by that code, so the catch block compiles
* fine and simply stops catching the Jackson half. This is the dangerous case,
* and it is by far the more common shape in real code.
*
* Case B is what runs below.
*/
public class Y01UncheckedExceptions {
public record OrderDto(Long orderId, String customerName) { }
public static void main(String[] args) {
System.out.println("JacksonException extends RuntimeException : "
+ RuntimeException.class.isAssignableFrom(JacksonException.class));
System.out.println("JacksonException extends IOException : "
+ IOException.class.isAssignableFrom(JacksonException.class));
System.out.println();
JsonMapper mapper = JsonMapper.builder().build();
String malformed = "{\"orderId\": not-json}";
// CASE B: genuine I/O plus a Jackson call in the same try block. Compiles
// cleanly, catches the I/O half, and lets the Jackson half straight through.
System.out.println("-- catch (IOException) around I/O + Jackson --");
try {
try (StringReader reader = new StringReader(malformed)) {
reader.read(); // makes IOException reachable
mapper.readValue(malformed, OrderDto.class); // no longer covered
System.out.println(" unreachable");
} catch (IOException e) {
System.out.println(" caught by IOException handler");
}
} catch (JacksonException escaped) {
System.out.println(" ESCAPED the IOException handler -> "
+ escaped.getClass().getSimpleName());
}
// The correct Jackson 3 idiom: catch both, separately.
System.out.println("-- catch (JacksonException) then catch (IOException) --");
try (StringReader reader = new StringReader(malformed)) {
reader.read();
mapper.readValue(malformed, OrderDto.class);
} catch (JacksonException e) {
System.out.println(" caught: " + e.getClass().getSimpleName());
} catch (IOException e) {
System.out.println(" I/O error: " + e.getClass().getSimpleName());
}
// The upside of unchecked: Jackson calls now compose inside lambdas without a
// wrapper, which was genuinely painful in Jackson 2.
System.out.println("-- unchecked exceptions inside a stream --");
List<String> json = Stream.of(new OrderDto(1L, "Alice"), new OrderDto(2L, "Bob"))
.map(mapper::writeValueAsString) // no try/catch, no helper needed
.toList();
System.out.println(" " + json);
}
}

View File

@@ -0,0 +1,46 @@
package com.ankurm.jackson3.beyond;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.json.JsonMapper;
/**
* BEYOND THE POSTS — a default that flipped between Jackson 2 and Jackson 3.
*
* FAIL_ON_TRAILING_TOKENS was OFF in Jackson 2 and is ON in Jackson 3. Concatenated
* or double-encoded JSON that Jackson 2 quietly accepted (reading the first document
* and discarding the rest) now throws. This is a correctness improvement, but it will
* surface as new runtime failures on payloads that used to "work".
*/
public class Y02TrailingTokens {
public record OrderDto(Long orderId) { }
public static void main(String[] args) {
JsonMapper strict = JsonMapper.builder().build(); // Jackson 3 default
JsonMapper relaxed = JsonMapper.builder()
.disable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) // Jackson 2 behaviour
.build();
System.out.println("FAIL_ON_TRAILING_TOKENS default : "
+ strict.deserializationConfig().isEnabled(
DeserializationFeature.FAIL_ON_TRAILING_TOKENS));
System.out.println();
String concatenated = "{\"orderId\":1} {\"orderId\":2}";
System.out.println("Jackson 3 default -> " + read(strict, concatenated));
System.out.println("2.x behaviour -> " + read(relaxed, concatenated));
String trailingGarbage = "{\"orderId\":1}garbage";
System.out.println("garbage, default -> " + read(strict, trailingGarbage));
System.out.println("garbage, relaxed -> " + read(relaxed, trailingGarbage));
}
private static String read(JsonMapper mapper, String json) {
try {
return "accepted: " + mapper.readValue(json, OrderDto.class);
} catch (Exception e) {
return "rejected: " + e.getClass().getSimpleName();
}
}
}

View File

@@ -0,0 +1,62 @@
package com.ankurm.jackson3.beyond;
import tools.jackson.databind.cfg.DateTimeFeature;
import tools.jackson.databind.json.JsonMapper;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
/**
* BEYOND THE POSTS — where WRITE_DATES_AS_TIMESTAMPS actually lives.
*
* Several of the blog snippets carry `.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)`
* over from Jackson 2. That constant does NOT exist on SerializationFeature in Jackson 3;
* the code does not compile. The flag moved to tools.jackson.databind.cfg.DateTimeFeature
* and, more usefully, it now defaults to OFF — so ISO-8601 output needs no configuration
* at all and there is nothing to disable.
*/
public class Y03DateTimeDefaults {
public record Meeting(LocalDate day, LocalDateTime startsAt,
Instant recordedAt, ZonedDateTime zoned, Duration length) { }
public static void main(String[] args) {
System.out.println("SerializationFeature has WRITE_DATES_AS_TIMESTAMPS : "
+ hasSerializationFeature("WRITE_DATES_AS_TIMESTAMPS"));
System.out.println("DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS default : "
+ JsonMapper.builder().build().serializationConfig()
.isEnabled(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS));
System.out.println();
Meeting meeting = new Meeting(
LocalDate.of(2026, 9, 15),
LocalDateTime.of(2026, 9, 15, 10, 30),
Instant.parse("2026-09-15T10:30:00Z"),
ZonedDateTime.parse("2026-09-15T10:30:00Z"),
Duration.ofMinutes(45));
JsonMapper defaults = JsonMapper.builder().build();
System.out.println("defaults (ISO-8601):");
System.out.println(" " + defaults.writeValueAsString(meeting));
JsonMapper timestamps = JsonMapper.builder()
.enable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();
System.out.println("with WRITE_DATES_AS_TIMESTAMPS enabled:");
System.out.println(" " + timestamps.writeValueAsString(meeting));
// Both forms read back, so an upgrade does not break existing stored payloads.
String numeric = timestamps.writeValueAsString(meeting);
System.out.println("numeric form reads back: " + defaults.readValue(numeric, Meeting.class).day());
}
private static boolean hasSerializationFeature(String name) {
for (var f : tools.jackson.databind.SerializationFeature.values()) {
if (f.name().equals(name)) return true;
}
return false;
}
}

View File

@@ -0,0 +1,56 @@
package com.ankurm.jackson3.beyond;
import com.fasterxml.jackson.annotation.JsonInclude;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.PropertyNamingStrategies;
import tools.jackson.databind.json.JsonMapper;
/**
* BEYOND THE POSTS — how to vary configuration once the mapper is immutable.
*
* The Jackson 2 habit of grabbing the shared mapper and calling configure() on it is
* impossible in Jackson 3 — there are no mutators. The replacements are rebuild(),
* which forks a builder from an existing mapper, and reader()/writer() views for
* per-call tweaks. Neither disturbs the shared instance.
*/
public class Y04ImmutableMapperAndReaders {
public record UserProfile(String firstName, String lastName, String middleName) { }
public static void main(String[] args) {
JsonMapper shared = JsonMapper.builder().build();
UserProfile user = new UserProfile("Ada", "Lovelace", null);
System.out.println("shared : " + shared.writeValueAsString(user));
// 1. rebuild(): fork the shared mapper's configuration and change one thing.
JsonMapper snakeCase = shared.rebuild()
.propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.changeDefaultPropertyInclusion(i -> i.withValueInclusion(JsonInclude.Include.NON_NULL))
.build();
System.out.println("rebuilt snake_case : " + snakeCase.writeValueAsString(user));
// 2. The shared mapper is untouched by the fork.
System.out.println("shared unchanged : " + shared.writeValueAsString(user));
// 3. writer()/reader() views for a single call, no new mapper needed.
System.out.println("writer view pretty : "
+ shared.writer().withDefaultPrettyPrinter().writeValueAsString(user)
.replace("\n", " ").replaceAll("\\s+", " "));
System.out.println("reader view strict : " + readStrict(shared));
}
private static String readStrict(JsonMapper shared) {
String json = "{\"firstName\":\"Ada\",\"lastName\":\"Lovelace\",\"unexpected\":1}";
try {
shared.reader()
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.forType(UserProfile.class) // ObjectReader has no readValue(String, Class)
.readValue(json);
return "accepted";
} catch (Exception e) {
return "rejected (" + e.getClass().getSimpleName() + ") without touching the shared mapper";
}
}
}

View File

@@ -0,0 +1,55 @@
package com.ankurm.jackson3.beyond;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import tools.jackson.databind.MapperFeature;
import tools.jackson.databind.json.JsonMapper;
import java.util.Arrays;
/**
* BEYOND THE POSTS — checking a claim rather than repeating it.
*
* The comparison post states that MapperFeature.AUTO_DETECT_CREATORS was removed and
* that, as a result, "any class relying on a single-argument constructor being detected
* without an annotation will quietly fail."
*
* Half right. The enum constant is genuinely gone. But the BEHAVIOUR it controlled is
* still there: a single-argument constructor is still detected as a delegating creator.
* The wrapper below deserialises with no @JsonCreator at all. Annotate anyway — it is
* explicit and free — but do not expect the upgrade to break these classes.
*/
public class Y05CreatorDetection {
/** No @JsonCreator anywhere. */
public static class ImplicitOrderId {
private final String value;
public ImplicitOrderId(String value) { this.value = value; }
@JsonValue public String value() { return value; }
@Override public String toString() { return "ImplicitOrderId[" + value + "]"; }
}
/** The explicit form, which is what you should write. */
public static class ExplicitOrderId {
private final String value;
@JsonCreator public ExplicitOrderId(String value) { this.value = value; }
@JsonValue public String value() { return value; }
@Override public String toString() { return "ExplicitOrderId[" + value + "]"; }
}
public static void main(String[] args) {
boolean present = Arrays.stream(MapperFeature.values())
.anyMatch(f -> f.name().equals("AUTO_DETECT_CREATORS"));
System.out.println("MapperFeature.AUTO_DETECT_CREATORS exists : " + present);
System.out.println("Nearest surviving features : "
+ Arrays.stream(MapperFeature.values())
.filter(f -> f.name().contains("CREATOR") || f.name().contains("PARAMETER_NAMES"))
.map(Enum::name).toList());
System.out.println();
JsonMapper mapper = JsonMapper.builder().build();
System.out.println("implicit single-arg ctor : " + mapper.readValue("\"ord-1\"", ImplicitOrderId.class));
System.out.println("explicit @JsonCreator : " + mapper.readValue("\"ord-2\"", ExplicitOrderId.class));
System.out.println("round-trip via @JsonValue: " + mapper.writeValueAsString(new ImplicitOrderId("ord-3")));
}
}

View File

@@ -0,0 +1,69 @@
package com.ankurm.jackson3.beyond;
import tools.jackson.core.json.JsonFactory;
import tools.jackson.core.util.JsonRecyclerPools;
import tools.jackson.core.util.RecyclerPool;
import tools.jackson.databind.json.JsonMapper;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* BEYOND THE POSTS — the performance knob the comparison post names but never measures.
*
* Jackson 3 changed the default buffer RecyclerPool. The post says to restore the 2.x
* thread-local pool if you see a regression. Whether that helps depends entirely on your
* concurrency profile, so this measures it on the machine you are actually running on
* instead of asserting a winner.
*
* Indicative timings, not JMH. Run it a few times; the numbers move.
*/
public class Y06RecyclerPoolTuning {
public record Payload(long id, String name, List<String> tags, double amount) { }
private static final int ITERATIONS = 40_000;
public static void main(String[] args) throws Exception {
System.out.println("default pool : "
+ JsonRecyclerPools.defaultPool().getClass().getSimpleName());
System.out.println("cores : " + Runtime.getRuntime().availableProcessors());
System.out.println();
for (int threads : new int[] { 1, 8 }) {
System.out.println("--- " + threads + " thread(s), " + ITERATIONS + " round-trips each ---");
run(threads, "threadLocalPool (2.x default)", JsonRecyclerPools.threadLocalPool());
run(threads, "concurrentDeque (3.x default)", JsonRecyclerPools.newConcurrentDequePool());
run(threads, "nonRecyclingPool (no reuse) ", JsonRecyclerPools.nonRecyclingPool());
System.out.println();
}
}
private static void run(int threads, String label, RecyclerPool<?> pool) throws Exception {
@SuppressWarnings({ "unchecked", "rawtypes" })
JsonFactory factory = JsonFactory.builder().recyclerPool((RecyclerPool) pool).build();
JsonMapper mapper = JsonMapper.builder(factory).build();
Payload sample = new Payload(1L, "example", List.of("a", "b", "c"), 12.5);
for (int i = 0; i < 2_000; i++) { // warm up
mapper.readValue(mapper.writeValueAsString(sample), Payload.class);
}
ExecutorService pooled = Executors.newFixedThreadPool(threads);
long start = System.nanoTime();
var tasks = java.util.stream.IntStream.range(0, threads)
.<Callable<Void>>mapToObj(t -> () -> {
for (int i = 0; i < ITERATIONS; i++) {
mapper.readValue(mapper.writeValueAsString(sample), Payload.class);
}
return null;
}).toList();
for (var future : pooled.invokeAll(tasks)) future.get();
long ms = (System.nanoTime() - start) / 1_000_000;
pooled.shutdown();
System.out.printf(" %-34s %5d ms%n", label, ms);
}
}