1
0
Files
jackson3-by-example/docs/beyond.md
Ankur c438afc33b 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.
2026-08-04 23:29:26 +05:30

6.4 KiB

Beyond the posts

Behaviour that no post in the series covers, but that shows up on the first real upgrade. Every claim here is printed by a program rather than asserted.

Y01 — unchecked exceptions

Y01UncheckedExceptions.java

JacksonException extends RuntimeException. The comparison post is right that this is the most dangerous change, but the failure mode is more specific than "your catch blocks stop working". There are two cases:

  • 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.
  • The try block also does real I/O. IOException is still reachable, so the catch block compiles and simply stops covering the Jackson call.

The second is the common shape in real code — read a request body, parse it — and it is what runs below.

JacksonException extends RuntimeException : true
JacksonException extends IOException      : false

-- catch (IOException) around I/O + Jackson --
   ESCAPED the IOException handler -> StreamReadException
-- catch (JacksonException) then catch (IOException) --
   caught: StreamReadException
-- unchecked exceptions inside a stream --
   [{"orderId":1,"customerName":"Alice"}, {"orderId":2,"customerName":"Bob"}]

The upside is real too: Jackson calls now compose inside lambdas and streams without a checked-exception wrapper, as the last line shows.

Y02 — FAIL_ON_TRAILING_TOKENS

Y02TrailingTokens.java

Off in Jackson 2, on in Jackson 3. Concatenated or double-encoded JSON that used to parse — reading the first document and discarding the rest — now throws.

FAIL_ON_TRAILING_TOKENS default : true

Jackson 3 default -> rejected: MismatchedInputException
2.x behaviour     -> accepted: OrderDto[orderId=1]
garbage, default  -> rejected: StreamReadException
garbage, relaxed  -> accepted: OrderDto[orderId=1]

A correctness improvement, but it surfaces as new runtime failures on payloads that previously "worked", which is a bad thing to discover in production.

Y03 — where WRITE_DATES_AS_TIMESTAMPS went

Y03DateTimeDefaults.java

Several blog snippets carry .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) over from Jackson 2. That constant is not on SerializationFeature in Jackson 3, so the code does not compile. It moved to tools.jackson.databind.cfg.DateTimeFeature and defaults to off, so there is nothing to disable.

SerializationFeature has WRITE_DATES_AS_TIMESTAMPS : false
DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS default  : false

defaults (ISO-8601):
  {"day":"2026-09-15","startsAt":"2026-09-15T10:30:00","recordedAt":"2026-09-15T10:30:00Z","zoned":"2026-09-15T10:30:00Z","length":"PT45M"}
with WRITE_DATES_AS_TIMESTAMPS enabled:
  {"day":[2026,9,15],"startsAt":[2026,9,15,10,30],"recordedAt":1789468200.000000000,"zoned":1789468200.000000000,"length":"PT45M"}
numeric form reads back: 2026-09-15

Both forms read back, so stored payloads written by an older service still parse.

Y04 — configuring an immutable mapper

Y04ImmutableMapperAndReaders.java

The Jackson 2 habit of fetching the shared mapper and calling configure() on it has no equivalent. Two replacements: rebuild() forks a builder from an existing mapper, and reader()/writer() give per-call views.

shared              : {"firstName":"Ada","lastName":"Lovelace","middleName":null}
rebuilt snake_case  : {"first_name":"Ada","last_name":"Lovelace"}
shared unchanged    : {"firstName":"Ada","lastName":"Lovelace","middleName":null}
writer view pretty  : { "firstName" : "Ada", "lastName" : "Lovelace", "middleName" : null }
reader view strict  : rejected (UnrecognizedPropertyException) without touching the shared mapper

ObjectReader has no readValue(String, Class) overload — use .forType(X.class) then .readValue(json).

Y05 — creator detection

Y05CreatorDetection.java

The comparison post says removing MapperFeature.AUTO_DETECT_CREATORS means "any class relying on a single-argument constructor being detected without an annotation will quietly fail". Half right: the enum constant is gone, but the behaviour is not.

MapperFeature.AUTO_DETECT_CREATORS exists : false
Nearest surviving features                : [INFER_CREATOR_FROM_CONSTRUCTOR_PROPERTIES, DETECT_PARAMETER_NAMES, SORT_CREATOR_PROPERTIES_FIRST]

implicit single-arg ctor : ImplicitOrderId[ord-1]
explicit @JsonCreator    : ExplicitOrderId[ord-2]
round-trip via @JsonValue: "ord-3"

Annotate with @JsonCreator anyway — it is explicit and costs nothing — but do not budget upgrade time for classes that will not actually break.

Y06 — RecyclerPool

Y06RecyclerPoolTuning.java

Jackson 3 changed the default buffer pool. The post says to restore the 2.x thread-local pool if you see a regression, but does not measure it. Whether it helps depends entirely on your concurrency profile, so measure on your own hardware.

default pool : ConcurrentDequePool
cores        : 2

--- 1 thread(s), 40000 round-trips each ---
  threadLocalPool   (2.x default)      301 ms
  concurrentDeque   (3.x default)      310 ms
  nonRecyclingPool  (no reuse)         496 ms

--- 8 thread(s), 40000 round-trips each ---
  threadLocalPool   (2.x default)      451 ms
  concurrentDeque   (3.x default)      450 ms
  nonRecyclingPool  (no reuse)         808 ms

On this 2-core container the two pools land within noise of each other in both shapes, and which one wins moves between runs — so the honest reading is that there is no default winner here and the post's advice is worth testing rather than applying blind. Re-run this a few times before concluding anything; the figures above are a single shot.

What is stable across runs is the third line: disabling recycling entirely is consistently the slowest, and by a wide margin under concurrency. That is the useful negative control — it confirms the pool is doing real work, so the choice between the two pooling strategies is a tuning decision rather than a correctness one.