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 c438afc33b
94 changed files with 3387 additions and 0 deletions

18
docs/README.md Normal file
View File

@@ -0,0 +1,18 @@
# Documentation
One page per post in the series. Each page lists the examples for that post, states
where the code deviates from the published snippet and why, and embeds the real
captured output.
- [Part 0 — Jackson 101: setup and the ObjectMapper lifecycle](part0-setup.md)
- [Part 1 — ObjectMapper: reading and writing](part1-objectmapper.md)
- [Part 2 — Records, Optionals and sealed types](part2-modern-java.md)
- [Part 3 — Annotations](part3-annotations.md)
- [Part 4 — Custom serialisers, deserialisers and mix-ins](part4-custom.md)
- [Part 5 — Polymorphic deserialisation](part5-polymorphic.md)
- [Part 6 — Streaming API and the tree model](part6-streaming.md)
- [Part 7 — Security](part7-security.md)
- [Beyond the posts](beyond.md)
Raw captured stdout for every program is in [`output/`](output). Those files are
regenerated by [`../run-all.sh`](../run-all.sh); do not edit them by hand.

149
docs/beyond.md Normal file
View File

@@ -0,0 +1,149 @@
# 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.

View File

@@ -0,0 +1,2 @@
{"listPrice":79.99,"productId":1,"productName":"Mechanical Keyboard"}
Mechanical Keyboard

View File

@@ -0,0 +1,3 @@
configured : {"invoiceId":500,"customerName":"Alice","issuedOn":"2026-04-09"}
defaults : {"invoiceId":500,"customerName":"Alice","issuedOn":"2026-04-09","note":null}
ObjectMapper set*() methods in Jackson 3: 0

View File

@@ -0,0 +1,3 @@
1. data binding : Order[orderId=1001, status=SHIPPED]
2. tree model : orderId=1001 status=SHIPPED
3. streaming : orderId=1001 status=SHIPPED

View File

@@ -0,0 +1,8 @@
{"articleId":1,"title":"Jackson Deep Dive","tags":["java","json"]}
file : {"articleId":1,"title":"Jackson Deep Dive","tags":["java","json"]}
pretty :
{
"articleId" : 1,
"title" : "Jackson Deep Dive",
"tags" : [ "java", "json" ]
}

View File

@@ -0,0 +1,3 @@
from String : Jackson Deep Dive
from File : 1
from Stream : [java, json]

View File

@@ -0,0 +1,6 @@
size : 2
element class : Article
first title : First
raw element : LinkedHashMap <- not Article
cast fails : ClassCastException, as expected
map value : Nine

View File

@@ -0,0 +1,3 @@
POJO : {"listPrice":79.99,"productId":1,"productName":"Mechanical Keyboard"}
record : {"productId":1,"productName":"Mechanical Keyboard","listPrice":79.99}
ordered : {"productId":1,"productName":"Mechanical Keyboard","listPrice":79.99}

View File

@@ -0,0 +1,3 @@
{"productId":101,"productName":"Wireless Keyboard","unitPrice":49.99}
Wireless Keyboard
round-trip equal: true

View File

@@ -0,0 +1,5 @@
present : {"customerName":"Alice","middleName":"Marie"}
empty : {"customerName":"Bob","middleName":null}
absent : {"customerName":"Bob"}
isPresent: true
missing -> Optional.empty (null? false)

View File

@@ -0,0 +1,4 @@
Circle with radius: 5.0
Rectangle 10.0 x 4.0
lossy : [{"radius":5.0},{"width":10.0,"height":4.0}]
correct : [{"shapeType":"circle","radius":5.0},{"shapeType":"rectangle","width":10.0,"height":4.0}]

View File

@@ -0,0 +1,3 @@
Circle -> {"shapeType":"circle","radius":5.0} -> Circle[radius=5.0]
Rectangle -> {"shapeType":"rectangle","width":10.0,"height":4.0} -> Rectangle[width=10.0, height=4.0]
Triangle -> {"shapeType":"triangle","base":3.0,"height":6.0} -> Triangle[base=3.0, height=6.0]

View File

@@ -0,0 +1,4 @@
rename : {"order_id":1001,"customer_name":"Alice"}
read back : OrderSummary[orderId=1001, customerName=Alice]
ignore : {"username":"alice"}
read back : passwordHash=null

View File

@@ -0,0 +1,3 @@
NON_NULL : {"productName":"Keyboard"}
NON_EMPTY : {"productName":"Keyboard"}
formats : {"invoiceId":500,"defaultDate":"2026-04-09","ukStyleDate":"09/04/2026","totalAmount":"199.99"}

View File

@@ -0,0 +1,6 @@
alias {"q":"jackson"} -> jackson
alias {"query":"jackson"} -> jackson
alias {"search_term":"jackson"} -> jackson
default mapper : LenientResponse[status=OK, message=done]
strict mapper : UnrecognizedPropertyException (as expected)
strict + anno : OptedOutResponse[status=OK, message=done]

View File

@@ -0,0 +1,5 @@
creator : ImmutablePoint(x=3.5, y=7.2)
round-trip: {"x":3.5,"y":7.2}
unwrapped : {"street":"123 Main St","city":"Springfield","customerName":"Alice"}
any-setter: {surprise=1, another=[true, false]}
any-getter: {"knownField":"a","surprise":1,"another":[true,false]}

View File

@@ -0,0 +1,5 @@
serialised : {"amount":20.00,"currency":"USD"}
amount : 20.0 (scale lost)
amount exact : 20.00 (scale preserved)
missing field: Money[amount=0, currencyCode=EUR]
no module : {"amount":19.999,"currencyCode":"usd"}

View File

@@ -0,0 +1,2 @@
with mixin : {"city":"Springfield","street":"123 Main St","zip":"12345"}
without mixin: {"city":"Springfield","internalTrackingCode":"INTERNAL-X99","postalCode":"12345","street":"123 Main St"}

View File

@@ -0,0 +1,3 @@
custom : {"assignee":"u-42","title":"Fix build"}
default : {"assignee":{"value":"u-42"},"title":"Fix build"}
base class: tools.jackson.databind.ValueSerializer

View File

@@ -0,0 +1,20 @@
--- 1. single element: discriminator present ---
{"paymentType":"credit_card","amountDue":99.99,"cardNetwork":"VISA","cardNumberLastFour":"4242","paymentId":1}
--- 2. BROKEN: writeValueAsString(List) drops paymentType ---
[{"amountDue":99.99,"cardNetwork":"VISA","cardNumberLastFour":"4242","paymentId":1},{"amountDue":250.0,"bankAccountIban":"GB29NWBK60161331926819","bankName":"National Bank","paymentId":2}]
--- 3. FIX A: writerFor(TypeReference) ---
[ {
"paymentType" : "credit_card",
"amountDue" : 99.99,
"cardNetwork" : "VISA",
"cardNumberLastFour" : "4242",
"paymentId" : 1
}, {
"paymentType" : "bank_transfer",
"amountDue" : 250.0,
"bankAccountIban" : "GB29NWBK60161331926819",
"bankName" : "National Bank",
"paymentId" : 2
} ]
--- 4. FIX B: a typed array carries its component type ---
[{"paymentType":"credit_card","amountDue":99.99,"cardNetwork":"VISA","cardNumberLastFour":"4242","paymentId":1},{"paymentType":"bank_transfer","amountDue":250.0,"bankAccountIban":"GB29NWBK60161331926819","bankName":"National Bank","paymentId":2}]

View File

@@ -0,0 +1,4 @@
Card ending: 4242
Bank: National Bank
lossy JSON round-trip -> InvalidTypeIdException
correct JSON round-trip -> 2 payments, CreditCardPayment first

View File

@@ -0,0 +1,7 @@
PROPERTY : {"kind":"card","amountDue":99.0}
WRAPPER_OBJECT : {"card":{"amountDue":99.0}}
WRAPPER_ARRAY : ["card",{"amountDue":99.0}]
EXISTING_PROPERTY : {"kind":"card","amountDue":99.0}
read PROPERTY -> PropCard[amountDue=99.0]
read WRAPPER_OBJECT -> WrapObjCard[amountDue=99.0]
read WRAPPER_ARRAY -> WrapArrCard[amountDue=99.0]

View File

@@ -0,0 +1,3 @@
unknown logical name -> InvalidTypeIdException
attacker-supplied class name -> InvalidTypeIdException
missing discriminator -> InvalidTypeIdException

View File

@@ -0,0 +1,2 @@
ERROR: Database connection failed
Total errors found: 1

View File

@@ -0,0 +1,4 @@
records written : 1000000
file size : 30 MB
elapsed : 129 ms
heap delta : 0 MB <- the document is never held in memory

View File

@@ -0,0 +1,8 @@
Customer: Alice
KB-01 x2
MS-42 x1
Has discount: false
path(missing) : (class MissingNode)
get(missing) : null
deep path : '<default>'
treeToValue : CustomerRecord[name=Alice, tier=gold]

View File

@@ -0,0 +1,5 @@
input file : 20 MB, 200000 entries
data binding (readValue) errors=4000 524 ms heap delta 47 MB
tree model (readTree) errors=4000 341 ms heap delta 102 MB
streaming (JsonParser) errors=4000 78 ms heap delta 1 MB

View File

@@ -0,0 +1,3 @@
email : EmailNotification[a@example.com]
sms : SmsNotification[+441234567890]
attack: rejected with InvalidTypeIdException

View File

@@ -0,0 +1,14 @@
--- tools.jackson.databind.ObjectMapper ---
enableDefaultTyping ABSENT
activateDefaultTyping ABSENT
setSerializationInclusion ABSENT
registerModule ABSENT
addMixIn ABSENT
total set*() mutators: 0
--- tools.jackson.databind.json.JsonMapper.Builder ---
activateDefaultTyping present
deactivateDefaultTyping present
polymorphicTypeValidator present
changeDefaultPropertyInclusion present
serializationInclusion ABSENT

View File

@@ -0,0 +1,3 @@
allowed written : {"@class":"com.ankurm.jackson3.part7security.H03PolymorphicTypeValidatorAllowlist$Envelope","body":{"@class":"com.ankurm.jackson3.part7security.H03PolymorphicTypeValidatorAllowlist$SafePayload","note":"ok"}}
allowed read : SafePayload[ok]
rogue : rejected with InvalidTypeIdException

View File

@@ -0,0 +1,11 @@
--- Jackson 3 defaults ---
max nesting depth : 500
max number length : 1000
max string length : 100000000
max name length : 50000
max doc length : -1 (-1 = unlimited)
1200-deep nesting, default limits -> rejected (StreamConstraintsException)
20-deep nesting, strict limits -> rejected (StreamConstraintsException)
5-deep nesting, strict limits -> accepted
3KB string, strict limits -> rejected (StreamConstraintsException)

View File

@@ -0,0 +1,4 @@
as Object : {action=ship, quantity=3, extra={nested=[1, 2]}}
runtime type: java.util.LinkedHashMap <- a plain Map, no arbitrary class was instantiated
as DTO : MyRequestDto[action=ship, quantity=3]
bad input : rejected with InvalidFormatException

View File

@@ -0,0 +1,9 @@
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"}]

View File

@@ -0,0 +1,6 @@
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]

View File

@@ -0,0 +1,8 @@
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

View File

@@ -0,0 +1,5 @@
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

View File

@@ -0,0 +1,6 @@
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"

View File

@@ -0,0 +1,13 @@
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

60
docs/part0-setup.md Normal file
View File

@@ -0,0 +1,60 @@
# Part 0 — Jackson 101: setup and the ObjectMapper lifecycle
Post: <https://ankurm.com/jackson-java-tutorial/>
## A01 — first round-trip
[`A01FirstRoundTrip.java`](../src/main/java/com/ankurm/jackson3/part0setup/A01FirstRoundTrip.java)
A plain POJO out to JSON and back, using `JsonMapper.builder().build()` in place of
`new ObjectMapper()`. Note there is no `throws` clause: Jackson 3 exceptions are unchecked.
```
{"listPrice":79.99,"productId":1,"productName":"Mechanical Keyboard"}
Mechanical Keyboard
```
The property order is **alphabetical**, not declaration order — the post shows
`productId` first. That is not a Jackson 3 change; it is how getter-based POJOs have
always been introspected. Records behave differently. See
[Part 1 / B04](part1-objectmapper.md#b04--property-ordering).
## A02 — the shared mapper
[`A02SharedMapperConfiguration.java`](../src/main/java/com/ankurm/jackson3/part0setup/A02SharedMapperConfiguration.java)
The post's central rule — build once, share everywhere — is enforced by the API in
Jackson 3 rather than left to discipline. Three of the four lines people copy from
Jackson 2 configuration are unnecessary or invalid here:
| Jackson 2 line | Status in Jackson 3 |
|---|---|
| `.registerModule(new JavaTimeModule())` | Delete. Built in; the class does not exist under `tools.jackson`. |
| `.registerModule(new Jdk8Module())` | Delete. Same. |
| `.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)` | **Does not compile.** Moved to `DateTimeFeature`, and already off. |
| `.configure(FAIL_ON_UNKNOWN_PROPERTIES, false)` | Redundant — already the default. |
| `.setSerializationInclusion(NON_NULL)` | Replaced by `.changeDefaultPropertyInclusion(...)`. |
```
configured : {"invoiceId":500,"customerName":"Alice","issuedOn":"2026-04-09"}
defaults : {"invoiceId":500,"customerName":"Alice","issuedOn":"2026-04-09","note":null}
ObjectMapper set*() methods in Jackson 3: 0
```
The last line is the point: `tools.jackson.databind.ObjectMapper` has **zero** `set*`
methods. Reconfiguring a shared mapper is not discouraged in Jackson 3, it is impossible.
## A03 — the three processing models
[`A03ThreeProcessingModels.java`](../src/main/java/com/ankurm/jackson3/part0setup/A03ThreeProcessingModels.java)
The same payload read by data binding, the tree model and the streaming parser.
```
1. data binding : Order[orderId=1001, status=SHIPPED]
2. tree model : orderId=1001 status=SHIPPED
3. streaming : orderId=1001 status=SHIPPED
```
For what each approach costs on a real file, see
[Part 6 / G04](part6-streaming.md#g04--the-decision-table-measured).

View File

@@ -0,0 +1,69 @@
# Part 1 — ObjectMapper: reading and writing
Post: <https://ankurm.com/jackson-objectmapper-guide/>
## B01 — writing
[`B01WriteJson.java`](../src/main/java/com/ankurm/jackson3/part1objectmapper/B01WriteJson.java)
String, File and pretty-printed output.
```
{"articleId":1,"title":"Jackson Deep Dive","tags":["java","json"]}
file : {"articleId":1,"title":"Jackson Deep Dive","tags":["java","json"]}
pretty :
{
"articleId" : 1,
"title" : "Jackson Deep Dive",
"tags" : [ "java", "json" ]
}
```
## B02 — reading
[`B02ReadJson.java`](../src/main/java/com/ankurm/jackson3/part1objectmapper/B02ReadJson.java)
String, File and InputStream. The post's `readValue(new URL(...), ...)` example is
deliberately not reproduced — it makes a live network call, so it could not be part of
a reproducible run. The `InputStream` form is what an HTTP client hands you in practice.
```
from String : Jackson Deep Dive
from File : 1
from Stream : [java, json]
```
## B03 — generic collections
[`B03GenericCollections.java`](../src/main/java/com/ankurm/jackson3/part1objectmapper/B03GenericCollections.java)
`TypeReference` lives in `tools.jackson.core.type` in Jackson 3. The example also runs
the wrong version — `readValue(json, List.class)` — to show what it actually produces.
```
size : 2
element class : Article
first title : First
raw element : LinkedHashMap <- not Article
cast fails : ClassCastException, as expected
map value : Nine
```
The failure is deferred, not immediate: the call succeeds and returns a
`List<LinkedHashMap>`. You find out at the first cast, often far from the parse site.
## B04 — property ordering
[`B04PropertyOrdering.java`](../src/main/java/com/ankurm/jackson3/part1objectmapper/B04PropertyOrdering.java)
Beyond the post, but it explains why your first output does not look like the article's.
```
POJO : {"listPrice":79.99,"productId":1,"productName":"Mechanical Keyboard"}
record : {"productId":1,"productName":"Mechanical Keyboard","listPrice":79.99}
ordered : {"productId":1,"productName":"Mechanical Keyboard","listPrice":79.99}
```
Getter-based POJOs serialise alphabetically; records follow declaration order;
`@JsonPropertyOrder` overrides both. Worth pinning explicitly if the JSON is
cached, signed, or diffed against a fixture.

70
docs/part2-modern-java.md Normal file
View File

@@ -0,0 +1,70 @@
# Part 2 — Records, Optionals and sealed types
Post: <https://ankurm.com/jackson-java-records-optionals/>
The single biggest simplification in Jackson 3 lands here. All three features are in
core: the project's [`pom.xml`](../pom.xml) has one dependency, no
`jackson-datatype-jdk8`, no `jackson-module-parameter-names`, and no `-parameters`
compiler argument.
## C01 — records
[`C01RecordRoundTrip.java`](../src/main/java/com/ankurm/jackson3/part2modernjava/C01RecordRoundTrip.java)
```
{"productId":101,"productName":"Wireless Keyboard","unitPrice":49.99}
Wireless Keyboard
round-trip equal: true
```
The post's own dependency block here is wrong twice over: it suggests
`com.fasterxml.jackson.module:jackson-module-parameter-names:3.1.2`, which is both an
artifact Jackson 3 does not need and a version that does not exist at that coordinate.
## C02 — Optional
[`C02OptionalFields.java`](../src/main/java/com/ankurm/jackson3/part2modernjava/C02OptionalFields.java)
```
present : {"customerName":"Alice","middleName":"Marie"}
empty : {"customerName":"Bob","middleName":null}
absent : {"customerName":"Bob"}
isPresent: true
missing -> Optional.empty (null? false)
```
Two behaviours worth committing to memory: an empty `Optional` serialises as `null`
unless you ask for `NON_ABSENT`, and a *missing* property deserialises to
`Optional.empty()` rather than to `null`, so the field is never null.
## C03 — sealed types with an explicit registry
[`C03SealedWithSubTypes.java`](../src/main/java/com/ankurm/jackson3/part2modernjava/C03SealedWithSubTypes.java)
```
Circle with radius: 5.0
Rectangle 10.0 x 4.0
lossy : [{"radius":5.0},{"width":10.0,"height":4.0}]
correct : [{"shapeType":"circle","radius":5.0},{"shapeType":"rectangle","width":10.0,"height":4.0}]
```
Note the `lossy` line. Serialising a `List<Shape>` drops the `shapeType` discriminator,
because a `List` gives Jackson no element type to dispatch on. This is the same defect
covered at length in [Part 5](part5-polymorphic.md).
## C04 — sealed auto-discovery
[`C04SealedAutoDiscovery.java`](../src/main/java/com/ankurm/jackson3/part2modernjava/C04SealedAutoDiscovery.java)
Beyond the post: Jackson 3 reads the `permits` clause, so `@JsonSubTypes` can be dropped
entirely when each permitted type carries `@JsonTypeName`. There is no `@JsonSubTypes`
anywhere in that file, and a third subtype added later just works.
```
Circle -> {"shapeType":"circle","radius":5.0} -> Circle[radius=5.0]
Rectangle -> {"shapeType":"rectangle","width":10.0,"height":4.0} -> Rectangle[width=10.0, height=4.0]
Triangle -> {"shapeType":"triangle","base":3.0,"height":6.0} -> Triangle[base=3.0, height=6.0]
```
In Jackson 2 the `@JsonSubTypes` registry had to be maintained in parallel with
`permits`, and drifted whenever someone added a case.

74
docs/part3-annotations.md Normal file
View File

@@ -0,0 +1,74 @@
# Part 3 — Annotations
Post: <https://ankurm.com/jackson-annotations-guide/>
Every `@Json*` annotation is imported from `com.fasterxml.jackson.annotation`, even in
Jackson 3. `jackson-annotations` deliberately keeps the old group ID and package so a
single copy can serve Jackson 2 and Jackson 3 code on one classpath — the
[migration repo's coexistence module](https://ankurm.com/git.app/asmhatre/jackson2-to-3-migration)
proves that with both majors loaded at once.
## D01 — @JsonProperty and @JsonIgnore
[`D01RenameAndIgnore.java`](../src/main/java/com/ankurm/jackson3/part3annotations/D01RenameAndIgnore.java)
```
rename : {"order_id":1001,"customer_name":"Alice"}
read back : OrderSummary[orderId=1001, customerName=Alice]
ignore : {"username":"alice"}
read back : passwordHash=null
```
`@JsonIgnore` is bidirectional — the last line shows an injected `passwordHash` in the
input being discarded, which is the security-relevant half people forget.
## D02 — @JsonInclude and @JsonFormat
[`D02InclusionAndFormat.java`](../src/main/java/com/ankurm/jackson3/part3annotations/D02InclusionAndFormat.java)
```
NON_NULL : {"productName":"Keyboard"}
NON_EMPTY : {"productName":"Keyboard"}
formats : {"invoiceId":500,"defaultDate":"2026-04-09","ukStyleDate":"09/04/2026","totalAmount":"199.99"}
```
`defaultDate` carries no annotation and still comes out as `2026-04-09`. The post's
claim that "without `@JsonFormat`, Jackson writes `LocalDate` as a numeric array" was
true in Jackson 2; in Jackson 3 ISO-8601 is the default and the annotation is only
needed for a non-standard pattern such as `ukStyleDate`.
## D03 — @JsonAlias and unknown fields
[`D03AliasAndUnknownFields.java`](../src/main/java/com/ankurm/jackson3/part3annotations/D03AliasAndUnknownFields.java)
```
alias {"q":"jackson"} -> jackson
alias {"query":"jackson"} -> jackson
alias {"search_term":"jackson"} -> jackson
default mapper : LenientResponse[status=OK, message=done]
strict mapper : UnrecognizedPropertyException (as expected)
strict + anno : OptedOutResponse[status=OK, message=done]
```
The post frames `@JsonIgnoreProperties(ignoreUnknown = true)` as the per-class
alternative to disabling `FAIL_ON_UNKNOWN_PROPERTIES` globally. In Jackson 3 that
feature is already off, so the default mapper tolerates the extra field with no
annotation. The annotation earns its keep only on a mapper where you have deliberately
re-enabled strictness — which is the `strict` case above.
## D04 — creators, unwrapping and catch-alls
[`D04CreatorsAndUnwrapping.java`](../src/main/java/com/ankurm/jackson3/part3annotations/D04CreatorsAndUnwrapping.java)
`@JsonUnwrapped` appears in the post's summary table but is never demonstrated;
`@JsonAnyGetter`/`@JsonAnySetter` are not in the post at all, and are the cleanest way
to keep fields you did not model instead of silently dropping them.
```
creator : ImmutablePoint(x=3.5, y=7.2)
round-trip: {"x":3.5,"y":7.2}
unwrapped : {"street":"123 Main St","city":"Springfield","customerName":"Alice"}
any-setter: {surprise=1, another=[true, false]}
any-getter: {"knownField":"a","surprise":1,"another":[true,false]}
```

72
docs/part4-custom.md Normal file
View File

@@ -0,0 +1,72 @@
# Part 4 — Custom serialisers, deserialisers and mix-ins
Post: <https://ankurm.com/jackson-custom-serializer-mixin/>
This is the part with the most API churn. The post's code is Jackson 2 throughout.
| Jackson 2 | Jackson 3 |
|---|---|
| `JsonSerializer<T>` | `ValueSerializer<T>` |
| `JsonDeserializer<T>` | `ValueDeserializer<T>` |
| `SerializerProvider` | `SerializationContext` |
| `com.fasterxml.jackson.databind.ser.std.StdSerializer` | `tools.jackson.databind.ser.std.StdSerializer` |
| `gen.writeNumberField` / `writeStringField` | `gen.writeNumberProperty` / `writeStringProperty` |
| `p.getCodec().readTree(p)` | `ctxt.readTree(p)` |
| `throws IOException` | removed — exceptions are unchecked |
| `new SimpleModule(name, Version)` | `new SimpleModule(name)` |
| `mapper.registerModule(m)` | `builder.addModule(m)` |
| `mapper.addMixIn(a, b)` | `builder.addMixIn(a, b)` |
## E01 / E02 — the handlers
[`E01MoneyValueSerializer.java`](../src/main/java/com/ankurm/jackson3/part4custom/E01MoneyValueSerializer.java) ·
[`E02MoneyValueDeserializer.java`](../src/main/java/com/ankurm/jackson3/part4custom/E02MoneyValueDeserializer.java) ·
[`Money.java`](../src/main/java/com/ankurm/jackson3/part4custom/Money.java)
The deserialiser uses `path()` rather than `get()`. The post's version calls
`rootNode.get("amount").decimalValue()`, which throws `NullPointerException` on
`{"currency":"USD"}`. There is a second trap: in Jackson 3, a bare `decimalValue()` on
a `MissingNode` throws, where Jackson 2 returned `BigDecimal.ZERO`. The defaulting
overload `decimalValue(BigDecimal.ZERO)` is what you want.
## E03 — registration
[`E03SimpleModuleRegistration.java`](../src/main/java/com/ankurm/jackson3/part4custom/E03SimpleModuleRegistration.java)
```
serialised : {"amount":20.00,"currency":"USD"}
amount : 20.0 (scale lost)
amount exact : 20.00 (scale preserved)
missing field: Money[amount=0, currencyCode=EUR]
no module : {"amount":19.999,"currencyCode":"usd"}
```
The post prints `20.00` for the deserialised amount. You get `20.0` — Jackson parses the
literal as a double before handing it over, so the scale is gone. If scale matters, and
for money it does, enable `USE_BIG_DECIMAL_FOR_FLOATS`, as the third line shows.
## E04 — mix-ins
[`E04MixinAnnotations.java`](../src/main/java/com/ankurm/jackson3/part4custom/E04MixinAnnotations.java)
```
with mixin : {"city":"Springfield","street":"123 Main St","zip":"12345"}
without mixin: {"city":"Springfield","internalTrackingCode":"INTERNAL-X99","postalCode":"12345","street":"123 Main St"}
```
The second line is the proof that nothing was modified at the bytecode level: a mapper
built without the mix-in still sees `internalTrackingCode` and `postalCode`.
## E05 — ValueSerializer directly
[`E05ValueSerializerDirect.java`](../src/main/java/com/ankurm/jackson3/part4custom/E05ValueSerializerDirect.java)
Beyond the post. `StdSerializer` kept its name, which hides the rename; extending
`ValueSerializer` directly makes it obvious, and is the leaner form for a simple wrapper.
```
custom : {"assignee":"u-42","title":"Fix build"}
default : {"assignee":{"value":"u-42"},"title":"Fix build"}
base class: tools.jackson.databind.ValueSerializer
```

111
docs/part5-polymorphic.md Normal file
View File

@@ -0,0 +1,111 @@
# Part 5 — Polymorphic deserialisation
Post: <https://ankurm.com/jackson-polymorphic-deserialization/>
This part contains the most consequential correction in the whole repo, so it gets the
most space.
## The defect
The post's "Serialising a Mixed List" section shows:
```java
List<PaymentMethod> payments = List.of(card, bank);
String jsonOutput = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(payments);
```
and prints output containing `"paymentType" : "credit_card"`.
It does not produce that. `writeValueAsString(Object)` sees only the runtime class of
the argument — `ImmutableCollections.ListN` — which carries no element type. Without a
declared element type Jackson never engages the polymorphic type serialiser, and the
discriminator is silently omitted from every element. No exception, no warning.
The consequence is not cosmetic. That JSON cannot be read back: the deserialiser has
no type id to dispatch on and throws `InvalidTypeIdException`. A service that writes
with the post's code and reads with the post's code does not round-trip.
## F01 — the defect and both fixes
[`F01SerialiseMixedList.java`](../src/main/java/com/ankurm/jackson3/part5polymorphic/F01SerialiseMixedList.java)
```
--- 1. single element: discriminator present ---
{"paymentType":"credit_card","amountDue":99.99,"cardNetwork":"VISA","cardNumberLastFour":"4242","paymentId":1}
--- 2. BROKEN: writeValueAsString(List) drops paymentType ---
[{"amountDue":99.99,"cardNetwork":"VISA","cardNumberLastFour":"4242","paymentId":1},{"amountDue":250.0,"bankAccountIban":"GB29NWBK60161331926819","bankName":"National Bank","paymentId":2}]
--- 3. FIX A: writerFor(TypeReference) ---
[ {
"paymentType" : "credit_card",
"amountDue" : 99.99,
"cardNetwork" : "VISA",
"cardNumberLastFour" : "4242",
"paymentId" : 1
}, {
"paymentType" : "bank_transfer",
"amountDue" : 250.0,
"bankAccountIban" : "GB29NWBK60161331926819",
"bankName" : "National Bank",
"paymentId" : 2
} ]
--- 4. FIX B: a typed array carries its component type ---
[{"paymentType":"credit_card","amountDue":99.99,"cardNetwork":"VISA","cardNumberLastFour":"4242","paymentId":1},{"paymentType":"bank_transfer","amountDue":250.0,"bankAccountIban":"GB29NWBK60161331926819","bankName":"National Bank","paymentId":2}]
```
Block 1 shows a single element serialising correctly, which is why this is easy to miss
in a unit test that only checks one object. Block 2 is the collection, discriminator
absent. Blocks 3 and 4 are the two fixes:
- `mapper.writerFor(new TypeReference<List<PaymentMethod>>() {})` — declares the element
type on the writer.
- `payments.toArray(new PaymentMethod[0])` — an array carries its component type at
runtime, so no extra declaration is needed.
## F02 — round-trip proof
[`F02DeserialiseMixedList.java`](../src/main/java/com/ankurm/jackson3/part5polymorphic/F02DeserialiseMixedList.java)
```
Card ending: 4242
Bank: National Bank
lossy JSON round-trip -> InvalidTypeIdException
correct JSON round-trip -> 2 payments, CreditCardPayment first
```
Deserialisation itself works exactly as the post describes; it is the serialisation side
that is wrong. The middle two lines are the demonstration: lossy output fails, correctly
written output survives.
## F03 — the four include strategies
[`F03IncludeStrategies.java`](../src/main/java/com/ankurm/jackson3/part5polymorphic/F03IncludeStrategies.java)
The post gives these as a table. Here they are executed, with the read-back to confirm
each wire format is symmetric.
```
PROPERTY : {"kind":"card","amountDue":99.0}
WRAPPER_OBJECT : {"card":{"amountDue":99.0}}
WRAPPER_ARRAY : ["card",{"amountDue":99.0}]
EXISTING_PROPERTY : {"kind":"card","amountDue":99.0}
read PROPERTY -> PropCard[amountDue=99.0]
read WRAPPER_OBJECT -> WrapObjCard[amountDue=99.0]
read WRAPPER_ARRAY -> WrapArrCard[amountDue=99.0]
```
`EXISTING_PROPERTY` needs `visible = true` and a real field of that name on the type;
without it the discriminator is written but not populated back onto the object.
## F04 — bad discriminators
[`F04UnknownTypeId.java`](../src/main/java/com/ankurm/jackson3/part5polymorphic/F04UnknownTypeId.java)
```
unknown logical name -> InvalidTypeIdException
attacker-supplied class name -> InvalidTypeIdException
missing discriminator -> InvalidTypeIdException
```
All three fail the same way, which is the reassuring answer: an unregistered logical
name, an attacker-supplied fully qualified class name, and a missing discriminator are
indistinguishable to the type resolver, and none of them instantiate anything.

80
docs/part6-streaming.md Normal file
View File

@@ -0,0 +1,80 @@
# Part 6 — Streaming API and the tree model
Post: <https://ankurm.com/jackson-streaming-api-jsonnode/>
Renames that break the post's code verbatim:
| Jackson 2 | Jackson 3 |
|---|---|
| `com.fasterxml.jackson.core.JsonFactory` | `tools.jackson.core.json.JsonFactory` (note the extra `.json`) |
| `parser.getCurrentName()` | `parser.currentName()` |
| `parser.getText()` | `parser.getString()` |
| `JsonToken.FIELD_NAME` | `JsonToken.PROPERTY_NAME` |
| `gen.writeNumberField` / `writeStringField` | `gen.writeNumberProperty` / `writeStringProperty` |
| `node.asText()` | `node.asString()` |
| `factory.createParser(file)` | `factory.createParser(ObjectReadContext.empty(), file)` |
The comparison post's performance section gives the import as
`tools.jackson.core.JsonFactory`, which does not resolve — the class is in the
`.json` subpackage.
## G01 — filtering a large array
[`G01StreamingParserFilter.java`](../src/main/java/com/ankurm/jackson3/part6streaming/G01StreamingParserFilter.java)
```
ERROR: Database connection failed
Total errors found: 1
```
## G02 — writing a million records
[`G02StreamingGenerator.java`](../src/main/java/com/ankurm/jackson3/part6streaming/G02StreamingGenerator.java)
The post's loop, kept at its full 1,000,000 iterations, with the heap delta measured so
"constant memory usage" is a number.
```
records written : 1000000
file size : 30 MB
elapsed : 129 ms
heap delta : 0 MB <- the document is never held in memory
```
## G03 — the tree model
[`G03TreeModelNavigation.java`](../src/main/java/com/ankurm/jackson3/part6streaming/G03TreeModelNavigation.java)
```
Customer: Alice
KB-01 x2
MS-42 x1
Has discount: false
path(missing) : (class MissingNode)
get(missing) : null
deep path : '<default>'
treeToValue : CustomerRecord[name=Alice, tier=gold]
```
The `path(missing)` / `get(missing)` pair is the whole argument for `path()`: it returns
a `MissingNode` that chains safely, where `get()` returns `null` and the next call NPEs.
## G04 — the decision table, measured
[`G04ThreeApproachesMeasured.java`](../src/main/java/com/ankurm/jackson3/part6streaming/G04ThreeApproachesMeasured.java)
The post closes with a table asserting relative memory and verbosity. This runs the same
filter three ways over a generated 20 MB file.
```
input file : 20 MB, 200000 entries
data binding (readValue) errors=4000 524 ms heap delta 47 MB
tree model (readTree) errors=4000 341 ms heap delta 102 MB
streaming (JsonParser) errors=4000 78 ms heap delta 1 MB
```
Single-shot measurements on a 2-core container, not JMH — treat the ordering as the
result, not the absolute figures. Two things do stand out and are stable across runs:
the tree model costs roughly twice the heap of data binding for the same document, and
streaming is the only approach whose heap does not scale with input size.

116
docs/part7-security.md Normal file
View File

@@ -0,0 +1,116 @@
# Part 7 — Security
Post: <https://ankurm.com/jackson-security-best-practices/>
## H01 — the safe pattern
[`H01SafePolymorphismByAnnotation.java`](../src/main/java/com/ankurm/jackson3/part7security/H01SafePolymorphismByAnnotation.java)
`@JsonTypeInfo(use = Id.NAME)` plus an explicit `@JsonSubTypes` registry. The permitted
set is fixed at compile time, so no payload can introduce a class of its own.
```
email : EmailNotification[a@example.com]
sms : SmsNotification[+441234567890]
attack: rejected with InvalidTypeIdException
```
This is the recommendation, and it is correct. Reach for anything below only if this
cannot express your model.
## H02 — what survived into Jackson 3
[`H02DefaultTypingRemoved.java`](../src/main/java/com/ankurm/jackson3/part7security/H02DefaultTypingRemoved.java)
The post's remediation snippet is Jackson 2:
```java
ObjectMapper mapper = new ObjectMapper();
mapper.activateDefaultTyping(safeTypeValidator, ObjectMapper.DefaultTyping.NON_FINAL, ...);
```
Neither line compiles against Jackson 3. The mapper has no mutators at all, and
`DefaultTyping` is no longer nested inside `ObjectMapper`.
```
--- tools.jackson.databind.ObjectMapper ---
enableDefaultTyping ABSENT
activateDefaultTyping ABSENT
setSerializationInclusion ABSENT
registerModule ABSENT
addMixIn ABSENT
total set*() mutators: 0
--- tools.jackson.databind.json.JsonMapper.Builder ---
activateDefaultTyping present
deactivateDefaultTyping present
polymorphicTypeValidator present
changeDefaultPropertyInclusion present
serializationInclusion ABSENT
```
A second correction, from the migration repo rather than this one: both guides state
that `enableDefaultTyping()` was "removed in 2.16". It is still present on
`ObjectMapper` in Jackson 2.22.1, deprecated. That changes the migration story — a
Jackson 2 codebase can still be compiling against it today, so Jackson 3 is where it
finally breaks, not 2.16.
## H03 — a provable allowlist
[`H03PolymorphicTypeValidatorAllowlist.java`](../src/main/java/com/ankurm/jackson3/part7security/H03PolymorphicTypeValidatorAllowlist.java)
The post's intent in working Jackson 3 form, with the negative test the post's own AI
prompt asks for.
```
allowed written : {"@class":"com.ankurm.jackson3.part7security.H03PolymorphicTypeValidatorAllowlist$Envelope","body":{"@class":"com.ankurm.jackson3.part7security.H03PolymorphicTypeValidatorAllowlist$SafePayload","note":"ok"}}
allowed read : SafePayload[ok]
rogue : rejected with InvalidTypeIdException
```
One trap the post does not mention: with `DefaultTyping.NON_FINAL`, Jackson writes a
type id for the **root** object too, so the root class must be allowlisted as well.
Allowlisting only the payload base type makes the happy path fail, not just the attack
path — which reads like a bug in your validator when it is actually correct behaviour.
## H04 — resource limits
[`H04StreamReadConstraints.java`](../src/main/java/com/ankurm/jackson3/part7security/H04StreamReadConstraints.java)
Beyond the post. Gadget attacks are not the only deserialisation risk: a few hundred
bytes of nested brackets can exhaust the stack. Jackson 3 ships defaults for this and
they are tunable.
```
--- Jackson 3 defaults ---
max nesting depth : 500
max number length : 1000
max string length : 100000000
max name length : 50000
max doc length : -1 (-1 = unlimited)
1200-deep nesting, default limits -> rejected (StreamConstraintsException)
20-deep nesting, strict limits -> rejected (StreamConstraintsException)
5-deep nesting, strict limits -> accepted
3KB string, strict limits -> rejected (StreamConstraintsException)
```
The 100 MB default string limit and unlimited document length are generous for a
public endpoint. Tighten both if you accept untrusted JSON.
## H05 — Object.class
[`H05NeverDeserialiseIntoObject.java`](../src/main/java/com/ankurm/jackson3/part7security/H05NeverDeserialiseIntoObject.java)
```
as Object : {action=ship, quantity=3, extra={nested=[1, 2]}}
runtime type: java.util.LinkedHashMap <- a plain Map, no arbitrary class was instantiated
as DTO : MyRequestDto[action=ship, quantity=3]
bad input : rejected with InvalidFormatException
```
Worth being precise here, because the post is slightly alarming about it: on a default
Jackson 3 mapper, `readValue(json, Object.class)` yields a plain `LinkedHashMap`. No
arbitrary class is instantiated. The danger returns only once default typing is active
(H03). The rule still holds — target a specific type and you get input validation as a
side effect, as the last line shows.