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

150 lines
6.4 KiB
Markdown

# 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`](../src/main/java/com/ankurm/jackson3/beyond/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`](../src/main/java/com/ankurm/jackson3/beyond/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`](../src/main/java/com/ankurm/jackson3/beyond/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`](../src/main/java/com/ankurm/jackson3/beyond/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`](../src/main/java/com/ankurm/jackson3/beyond/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`](../src/main/java/com/ankurm/jackson3/beyond/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.