Jackson 2 to 3 migration companion code
Three Maven modules - jackson2-before (2.22.1), jackson3-after (3.2.1) and a coexistence module with BOTH majors on one classpath - so every claim in the two migration guides is executed rather than asserted. Paired class names make the before/after outputs directly diffable via run-all.sh. Confirms the guides on wire-format equivalence (10-case suite, zero mismatches), classpath coexistence and the collapse of four artifacts into one. Corrects nine points, including that enableDefaultTyping() is still present in Jackson 2.22.1 rather than removed in 2.16, and that the published "after" mapper snippet does not compile.
This commit is contained in:
15
docs/README.md
Normal file
15
docs/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Documentation
|
||||
|
||||
One page per migration topic, each showing the Jackson 2 and Jackson 3 output
|
||||
side by side.
|
||||
|
||||
- [S01 — Mapper construction and immutability](s01-mapper-construction.md)
|
||||
- [S02 — The exception hierarchy](s02-exception-hierarchy.md)
|
||||
- [S03 — Custom serialiser and deserialiser renames](s03-custom-handlers.md)
|
||||
- [S04 — Modules, records, Optional and dates](s04-modules-and-records.md)
|
||||
- [S05 — Default typing](s05-default-typing.md)
|
||||
- [S07 — Defaults that flipped silently](s07-defaults-that-flipped.md)
|
||||
- [S06 / S08 — Coexistence and wire-format equivalence](coexistence.md)
|
||||
|
||||
Raw stdout for every program is in [`output/`](output), regenerated by
|
||||
[`../run-all.sh`](../run-all.sh).
|
||||
75
docs/coexistence.md
Normal file
75
docs/coexistence.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# S06 / S08 — Coexistence and wire-format equivalence
|
||||
|
||||
Guide: <https://ankurm.com/jackson-3-vs-jackson-2/> (FAQs)
|
||||
|
||||
The [`coexistence/`](../coexistence) module puts **both** major versions on one
|
||||
classpath. Its [`pom.xml`](../coexistence/pom.xml) declares
|
||||
`tools.jackson.core:jackson-databind:3.2.1` and
|
||||
`com.fasterxml.jackson.core:jackson-databind:2.22.1` together, with no exclusions and
|
||||
no shading.
|
||||
|
||||
## S06 — both majors in one class
|
||||
|
||||
[`S06BothOnOneClasspath.java`](../coexistence/src/main/java/com/ankurm/migration/coexist/S06BothOnOneClasspath.java)
|
||||
|
||||
```
|
||||
jackson 2 class : com.fasterxml.jackson.databind.ObjectMapper
|
||||
jackson 3 class : tools.jackson.databind.json.JsonMapper
|
||||
|
||||
jackson 2 output: {"booking_id":1,"travel_date":"2026-09-15"}
|
||||
jackson 3 output: {"booking_id":1,"travel_date":"2026-09-15"}
|
||||
wire-compatible : true
|
||||
|
||||
3 reads 2's JSON: Booking[id=1, travelDate=2026-09-15]
|
||||
2 reads 3's JSON: Booking[id=1, travelDate=2026-09-15]
|
||||
|
||||
annotation from : jackson-annotations-2.22.jar
|
||||
<- one jackson-annotations jar, used by both majors
|
||||
```
|
||||
|
||||
One DTO, annotated once with `@JsonProperty` imported from
|
||||
`com.fasterxml.jackson.annotation`, and both mappers honour it. That is precisely why
|
||||
`jackson-annotations` deliberately kept the old group ID and package while everything
|
||||
else moved to `tools.jackson`: it is the shared piece.
|
||||
|
||||
The last line resolves which jar the annotation actually came from at runtime —
|
||||
a single `jackson-annotations-2.22.jar`, serving both.
|
||||
|
||||
One thing the guides do not mention: Jackson 3's built-in `java.time` support does not
|
||||
extend to the Jackson 2 mapper sitting beside it. The coexistence module still needs
|
||||
`jackson-datatype-jsr310` and `jackson-datatype-jdk8` for the Jackson 2 side. Nothing is
|
||||
shared between them except the annotations.
|
||||
|
||||
## S08 — wire-format equivalence
|
||||
|
||||
[`S08WireFormatEquivalence.java`](../coexistence/src/main/java/com/ankurm/migration/coexist/S08WireFormatEquivalence.java)
|
||||
|
||||
The comparison post's final AI prompt asks for a cross-version regression suite. This is
|
||||
that suite, written and run. Ten cases go through both mappers and the JSON strings are
|
||||
compared exactly.
|
||||
|
||||
```
|
||||
plain record identical
|
||||
nested list identical
|
||||
map identical
|
||||
java.time identical
|
||||
Optional present identical
|
||||
Optional empty identical
|
||||
NON_NULL identical
|
||||
@JsonProperty identical
|
||||
@JsonFormat identical
|
||||
nulls and empties identical
|
||||
|
||||
cases : 10
|
||||
mismatches: 0
|
||||
Wire format is identical across both majors for these cases.
|
||||
```
|
||||
|
||||
Zero mismatches. The guides' claim that the serialised form is unchanged holds for
|
||||
records, nested lists, maps, `java.time`, present and empty `Optional`, `NON_NULL`
|
||||
inclusion, `@JsonProperty` renaming, `@JsonFormat` patterns, and empty collections.
|
||||
|
||||
This matters most if your JSON is cached, signed, hashed, or consumed by a system you
|
||||
do not control — those are the cases where a field-order or date-format change would be
|
||||
a production incident rather than a cosmetic difference. Run this suite against your own
|
||||
DTOs before switching such a service.
|
||||
4
docs/output/after-S01MapperConstruction.txt
Normal file
4
docs/output/after-S01MapperConstruction.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
output : {"id":1,"travelDate":"2026-09-15","seatPreference":"aisle"}
|
||||
forked indented : { "id" : 1, "travelDate" : "2026-09-15", "seatPreference" : "aisle" }
|
||||
original intact : false
|
||||
set*() mutators : 0 <- mutation is impossible, not merely discouraged
|
||||
5
docs/output/after-S02ExceptionHierarchy.txt
Normal file
5
docs/output/after-S02ExceptionHierarchy.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
JacksonException extends RuntimeException : true
|
||||
JacksonException extends IOException : false
|
||||
ESCAPED catch (IOException) : StreamReadException
|
||||
caught by catch (Jackson...) : StreamReadException
|
||||
writeValueAsString declares : [class tools.jackson.core.JacksonException] <- nothing checked
|
||||
3
docs/output/after-S03CustomHandlers.txt
Normal file
3
docs/output/after-S03CustomHandlers.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
serialised : {"amount":19.99,"currency":"USD"}
|
||||
deserialised : Money[amount=20.0, currencyCode=USD]
|
||||
base classes : tools.jackson.databind.ValueSerializer / tools.jackson.databind.ValueDeserializer
|
||||
4
docs/output/after-S04ModulesAndRecords.txt
Normal file
4
docs/output/after-S04ModulesAndRecords.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
no modules : {"id":1,"departure":"2026-09-15","seat":"12A"}
|
||||
round-trip : TravelPlan[id=1, departure=2026-09-15, seat=Optional[12A]]
|
||||
dates : {"d":"2026-09-15"}
|
||||
empty Optional : {"id":2,"departure":"2026-09-16","seat":null}
|
||||
7
docs/output/after-S05DefaultTyping.txt
Normal file
7
docs/output/after-S05DefaultTyping.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
enableDefaultTyping on Jackson 3 ObjectMapper : false
|
||||
activateDefaultTyping on Jackson 3 ObjectMapper : false
|
||||
activateDefaultTyping on JsonMapper.Builder : true
|
||||
|
||||
written : {"@class":"com.ankurm.migration.after.S05DefaultTyping$Envelope","body":{"@class":"com.ankurm.migration.after.S05DefaultTyping$SafePayload","note":"ok"}}
|
||||
read : SafePayload[ok]
|
||||
rogue : rejected with InvalidTypeIdException
|
||||
9
docs/output/after-S07DefaultsThatFlipped.txt
Normal file
9
docs/output/after-S07DefaultsThatFlipped.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
FAIL_ON_TRAILING_TOKENS : true
|
||||
FAIL_ON_UNKNOWN_PROPERTIES : false
|
||||
ALLOW_FINAL_FIELDS_AS_MUTATORS: false
|
||||
DEFAULT_VIEW_INCLUSION : false
|
||||
AUTO_DETECT_CREATORS exists : false
|
||||
|
||||
concatenated JSON -> rejected: MismatchedInputException
|
||||
trailing garbage -> rejected: StreamReadException
|
||||
unknown property -> accepted: OrderDto[orderId=1]
|
||||
4
docs/output/before-S01MapperConstruction.txt
Normal file
4
docs/output/before-S01MapperConstruction.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
output : {"id":1,"travelDate":"2026-09-15","seatPreference":"aisle"}
|
||||
after mutation : { "id" : 1, "travelDate" : "2026-09-15", "seatPreference" : "aisle" }
|
||||
mutation stuck : true
|
||||
set*() mutators : 40
|
||||
3
docs/output/before-S02ExceptionHierarchy.txt
Normal file
3
docs/output/before-S02ExceptionHierarchy.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
JsonProcessingException extends IOException : true
|
||||
caught by catch (IOException) : JsonParseException
|
||||
writeValueAsString declares : [class com.fasterxml.jackson.core.JsonProcessingException]
|
||||
3
docs/output/before-S03CustomHandlers.txt
Normal file
3
docs/output/before-S03CustomHandlers.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
serialised : {"amount":19.99,"currency":"USD"}
|
||||
deserialised : Money[amount=20.0, currencyCode=USD]
|
||||
base classes : com.fasterxml.jackson.databind.JsonSerializer / com.fasterxml.jackson.databind.JsonDeserializer
|
||||
4
docs/output/before-S04ModulesAndRecords.txt
Normal file
4
docs/output/before-S04ModulesAndRecords.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
with modules : {"id":1,"departure":"2026-09-15","seat":"12A"}
|
||||
round-trip : TravelPlan[id=1, departure=2026-09-15, seat=Optional[12A]]
|
||||
bare mapper : FAILS -> InvalidDefinitionException
|
||||
bare, dates only: FAILS -> InvalidDefinitionException
|
||||
3
docs/output/before-S05DefaultTyping.txt
Normal file
3
docs/output/before-S05DefaultTyping.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
enableDefaultTyping on Jackson 2.22 ObjectMapper : true
|
||||
written : {"@class":"com.ankurm.migration.before.S05DefaultTyping$Envelope","body":{"@class":"com.ankurm.migration.before.S05DefaultTyping$SafePayload","note":"ok"}}
|
||||
read : SafePayload[ok]
|
||||
9
docs/output/before-S07DefaultsThatFlipped.txt
Normal file
9
docs/output/before-S07DefaultsThatFlipped.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
FAIL_ON_TRAILING_TOKENS : false
|
||||
FAIL_ON_UNKNOWN_PROPERTIES : true
|
||||
ALLOW_FINAL_FIELDS_AS_MUTATORS: true
|
||||
DEFAULT_VIEW_INCLUSION : true
|
||||
AUTO_DETECT_CREATORS exists : true
|
||||
|
||||
concatenated JSON -> accepted: OrderDto[orderId=1]
|
||||
trailing garbage -> accepted: OrderDto[orderId=1]
|
||||
unknown property -> rejected: UnrecognizedPropertyException
|
||||
12
docs/output/coexistence-S06BothOnOneClasspath.txt
Normal file
12
docs/output/coexistence-S06BothOnOneClasspath.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
jackson 2 class : com.fasterxml.jackson.databind.ObjectMapper
|
||||
jackson 3 class : tools.jackson.databind.json.JsonMapper
|
||||
|
||||
jackson 2 output: {"booking_id":1,"travel_date":"2026-09-15"}
|
||||
jackson 3 output: {"booking_id":1,"travel_date":"2026-09-15"}
|
||||
wire-compatible : true
|
||||
|
||||
3 reads 2's JSON: Booking[id=1, travelDate=2026-09-15]
|
||||
2 reads 3's JSON: Booking[id=1, travelDate=2026-09-15]
|
||||
|
||||
annotation from : jackson-annotations-2.22.jar
|
||||
<- one jackson-annotations jar, used by both majors
|
||||
14
docs/output/coexistence-S08WireFormatEquivalence.txt
Normal file
14
docs/output/coexistence-S08WireFormatEquivalence.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
plain record identical
|
||||
nested list identical
|
||||
map identical
|
||||
java.time identical
|
||||
Optional present identical
|
||||
Optional empty identical
|
||||
NON_NULL identical
|
||||
@JsonProperty identical
|
||||
@JsonFormat identical
|
||||
nulls and empties identical
|
||||
|
||||
cases : 10
|
||||
mismatches: 0
|
||||
Wire format is identical across both majors for these cases.
|
||||
58
docs/s01-mapper-construction.md
Normal file
58
docs/s01-mapper-construction.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# S01 — Mapper construction and immutability
|
||||
|
||||
Guide: <https://ankurm.com/jackson-3-migration-guide/> (Steps 2 and 3)
|
||||
|
||||
[`before/S01MapperConstruction.java`](../jackson2-before/src/main/java/com/ankurm/migration/before/S01MapperConstruction.java) ·
|
||||
[`after/S01MapperConstruction.java`](../jackson3-after/src/main/java/com/ankurm/migration/after/S01MapperConstruction.java)
|
||||
|
||||
## The guide's "after" snippet does not compile
|
||||
|
||||
```java
|
||||
JsonMapper mapper = JsonMapper.builder()
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) // no such constant
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
.serializationInclusion(JsonInclude.Include.NON_NULL) // no such method
|
||||
.build();
|
||||
```
|
||||
|
||||
Two of those four lines are invalid against Jackson 3.2.1:
|
||||
|
||||
- `SerializationFeature.WRITE_DATES_AS_TIMESTAMPS` does not exist. The flag moved to
|
||||
`tools.jackson.databind.cfg.DateTimeFeature` and now defaults to off, so ISO-8601
|
||||
output needs no configuration and there is nothing to disable.
|
||||
- `JsonMapper.Builder.serializationInclusion(...)` does not exist. The real API is
|
||||
`changeDefaultPropertyInclusion(UnaryOperator<JsonInclude.Value>)`.
|
||||
|
||||
The third line is valid but redundant — `FAIL_ON_UNKNOWN_PROPERTIES` already defaults
|
||||
to `false` in Jackson 3.
|
||||
|
||||
## Output
|
||||
|
||||
**Jackson 2 — `jackson2-before`**
|
||||
|
||||
```
|
||||
output : {"id":1,"travelDate":"2026-09-15","seatPreference":"aisle"}
|
||||
after mutation : { "id" : 1, "travelDate" : "2026-09-15", "seatPreference" : "aisle" }
|
||||
mutation stuck : true
|
||||
set*() mutators : 40
|
||||
```
|
||||
|
||||
**Jackson 3 — `jackson3-after`**
|
||||
|
||||
```
|
||||
output : {"id":1,"travelDate":"2026-09-15","seatPreference":"aisle"}
|
||||
forked indented : { "id" : 1, "travelDate" : "2026-09-15", "seatPreference" : "aisle" }
|
||||
original intact : false
|
||||
set*() mutators : 0 <- mutation is impossible, not merely discouraged
|
||||
```
|
||||
|
||||
Both produce the same JSON, which is the reassuring part. The interesting lines are the
|
||||
last two of each.
|
||||
|
||||
In Jackson 2 the mapper stays mutable forever. `mapper.enable(INDENT_OUTPUT)` after the
|
||||
mapper has been injected into a dozen components takes effect for all of them —
|
||||
`mutation stuck : true`. Forty `set*` methods are available to do it with.
|
||||
|
||||
In Jackson 3 there are zero. To vary configuration you fork: `rebuild()` returns a
|
||||
builder seeded from the existing mapper, and the original is untouched
|
||||
(`original intact : false`).
|
||||
47
docs/s02-exception-hierarchy.md
Normal file
47
docs/s02-exception-hierarchy.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# S02 — The exception hierarchy
|
||||
|
||||
Guide: <https://ankurm.com/jackson-3-vs-jackson-2/> ("JacksonException Is Now Unchecked")
|
||||
|
||||
[`before/S02ExceptionHierarchy.java`](../jackson2-before/src/main/java/com/ankurm/migration/before/S02ExceptionHierarchy.java) ·
|
||||
[`after/S02ExceptionHierarchy.java`](../jackson3-after/src/main/java/com/ankurm/migration/after/S02ExceptionHierarchy.java)
|
||||
|
||||
The guide calls this the most dangerous change and it is right. But the failure mode is
|
||||
narrower than "every `catch (IOException)` silently stops catching", and the difference
|
||||
decides how much of your codebase you actually have to audit.
|
||||
|
||||
**Case 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 finds these for you. No audit needed.
|
||||
|
||||
**Case B — the try block also does real I/O.** `IOException` is still reachable from the
|
||||
I/O, so the catch block compiles, and it silently stops covering the Jackson call.
|
||||
Nothing warns you. This is the shape that reaches production, and it is what the
|
||||
example runs.
|
||||
|
||||
## Output
|
||||
|
||||
**Jackson 2 — `jackson2-before`**
|
||||
|
||||
```
|
||||
JsonProcessingException extends IOException : true
|
||||
caught by catch (IOException) : JsonParseException
|
||||
writeValueAsString declares : [class com.fasterxml.jackson.core.JsonProcessingException]
|
||||
```
|
||||
|
||||
**Jackson 3 — `jackson3-after`**
|
||||
|
||||
```
|
||||
JacksonException extends RuntimeException : true
|
||||
JacksonException extends IOException : false
|
||||
ESCAPED catch (IOException) : StreamReadException
|
||||
caught by catch (Jackson...) : StreamReadException
|
||||
writeValueAsString declares : [class tools.jackson.core.JacksonException] <- nothing checked
|
||||
```
|
||||
|
||||
So the audit target is narrower and more specific than "grep for `catch (IOException)`":
|
||||
it is `catch (IOException)` blocks that contain **both** I/O and a Jackson call. Those
|
||||
are the ones the compiler cannot help with.
|
||||
|
||||
Note the last line of each. Jackson 3 still *declares* `JacksonException` on
|
||||
`writeValueAsString`, but since it is unchecked, callers are not forced to handle it —
|
||||
which is what lets Jackson calls sit inside lambdas without a wrapper.
|
||||
43
docs/s03-custom-handlers.md
Normal file
43
docs/s03-custom-handlers.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# S03 — Custom serialiser and deserialiser renames
|
||||
|
||||
Guide: <https://ankurm.com/jackson-3-vs-jackson-2/> ("Custom Serializers and Deserializers: Class Renames")
|
||||
|
||||
[`before/S03CustomHandlers.java`](../jackson2-before/src/main/java/com/ankurm/migration/before/S03CustomHandlers.java) ·
|
||||
[`after/S03CustomHandlers.java`](../jackson3-after/src/main/java/com/ankurm/migration/after/S03CustomHandlers.java)
|
||||
|
||||
If your project has custom handlers, this is where the upgrade time goes. Every item
|
||||
below is a compile error until fixed.
|
||||
|
||||
| Jackson 2 | Jackson 3 |
|
||||
|---|---|
|
||||
| `JsonSerializer<T>` | `ValueSerializer<T>` |
|
||||
| `JsonDeserializer<T>` | `ValueDeserializer<T>` |
|
||||
| `SerializerProvider` | `SerializationContext` |
|
||||
| `throws IOException` | remove — unchecked now |
|
||||
| `gen.writeNumberField(...)` | `gen.writeNumberProperty(...)` |
|
||||
| `gen.writeStringField(...)` | `gen.writeStringProperty(...)` |
|
||||
| `p.getCodec().readTree(p)` | `ctxt.readTree(p)` |
|
||||
| `new SimpleModule(name, Version)` | `new SimpleModule(name)` |
|
||||
| `mapper.registerModule(m)` | `builder.addModule(m)` |
|
||||
|
||||
## Output
|
||||
|
||||
**Jackson 2 — `jackson2-before`**
|
||||
|
||||
```
|
||||
serialised : {"amount":19.99,"currency":"USD"}
|
||||
deserialised : Money[amount=20.0, currencyCode=USD]
|
||||
base classes : com.fasterxml.jackson.databind.JsonSerializer / com.fasterxml.jackson.databind.JsonDeserializer
|
||||
```
|
||||
|
||||
**Jackson 3 — `jackson3-after`**
|
||||
|
||||
```
|
||||
serialised : {"amount":19.99,"currency":"USD"}
|
||||
deserialised : Money[amount=20.0, currencyCode=USD]
|
||||
base classes : tools.jackson.databind.ValueSerializer / tools.jackson.databind.ValueDeserializer
|
||||
```
|
||||
|
||||
Identical JSON on both sides; only the base class names differ. That is the useful
|
||||
result — this is a mechanical rename, not a behavioural change, so it is safe to do in
|
||||
bulk with OpenRewrite and review as a diff.
|
||||
56
docs/s04-modules-and-records.md
Normal file
56
docs/s04-modules-and-records.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# S04 — Modules, records, Optional and dates
|
||||
|
||||
Guide: <https://ankurm.com/jackson-3-migration-guide/> (Step 5) and the "Dependency Footprint" table
|
||||
|
||||
[`before/S04ModulesAndRecords.java`](../jackson2-before/src/main/java/com/ankurm/migration/before/S04ModulesAndRecords.java) ·
|
||||
[`after/S04ModulesAndRecords.java`](../jackson3-after/src/main/java/com/ankurm/migration/after/S04ModulesAndRecords.java)
|
||||
|
||||
The clearest win in the whole migration. Compare the two poms:
|
||||
|
||||
**[`jackson2-before/pom.xml`](../jackson2-before/pom.xml)** — four artifacts plus a compiler flag:
|
||||
|
||||
```xml
|
||||
com.fasterxml.jackson.core:jackson-databind
|
||||
com.fasterxml.jackson.datatype:jackson-datatype-jsr310 <!-- java.time -->
|
||||
com.fasterxml.jackson.datatype:jackson-datatype-jdk8 <!-- Optional -->
|
||||
com.fasterxml.jackson.module:jackson-module-parameter-names <!-- records -->
|
||||
...
|
||||
<compilerArgs><arg>-parameters</arg></compilerArgs>
|
||||
```
|
||||
|
||||
**[`jackson3-after/pom.xml`](../jackson3-after/pom.xml)** — one artifact, no compiler args:
|
||||
|
||||
```xml
|
||||
tools.jackson.core:jackson-databind
|
||||
```
|
||||
|
||||
The three module classes have no `tools.jackson` equivalent, so leaving the
|
||||
`registerModule` calls in place is a compile error, not a no-op. Delete them.
|
||||
|
||||
## Output
|
||||
|
||||
**Jackson 2 — `jackson2-before`**
|
||||
|
||||
```
|
||||
with modules : {"id":1,"departure":"2026-09-15","seat":"12A"}
|
||||
round-trip : TravelPlan[id=1, departure=2026-09-15, seat=Optional[12A]]
|
||||
bare mapper : FAILS -> InvalidDefinitionException
|
||||
bare, dates only: FAILS -> InvalidDefinitionException
|
||||
```
|
||||
|
||||
**Jackson 3 — `jackson3-after`**
|
||||
|
||||
```
|
||||
no modules : {"id":1,"departure":"2026-09-15","seat":"12A"}
|
||||
round-trip : TravelPlan[id=1, departure=2026-09-15, seat=Optional[12A]]
|
||||
dates : {"d":"2026-09-15"}
|
||||
empty Optional : {"id":2,"departure":"2026-09-16","seat":null}
|
||||
```
|
||||
|
||||
The first two lines are byte-identical across versions. The difference is everything
|
||||
that had to be set up to get there.
|
||||
|
||||
The `bare mapper` lines in the Jackson 2 output are the point: without the modules, a
|
||||
Jackson 2 mapper cannot serialise `Optional` or `LocalDate` at all — it throws
|
||||
`InvalidDefinitionException`. The Jackson 3 mapper in the "after" file has no modules
|
||||
registered because there are none to register.
|
||||
56
docs/s05-default-typing.md
Normal file
56
docs/s05-default-typing.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# S05 — Default typing
|
||||
|
||||
Guides: <https://ankurm.com/jackson-3-migration-guide/> (Step 4) and
|
||||
<https://ankurm.com/jackson-security-best-practices/>
|
||||
|
||||
[`before/S05DefaultTyping.java`](../jackson2-before/src/main/java/com/ankurm/migration/before/S05DefaultTyping.java) ·
|
||||
[`after/S05DefaultTyping.java`](../jackson3-after/src/main/java/com/ankurm/migration/after/S05DefaultTyping.java)
|
||||
|
||||
## Two corrections
|
||||
|
||||
**`enableDefaultTyping()` was not removed in 2.16.** Both guides say it was. It is still
|
||||
on `ObjectMapper` in Jackson 2.22.1, deprecated. The "before" program prints the
|
||||
reflective check. This changes the migration story: the method survives every 2.x
|
||||
upgrade, so Jackson 3 is where a codebase using it finally fails to compile — which is
|
||||
also the guides' own argument for why the removal is a useful security forcing function.
|
||||
|
||||
**The remediation snippet is Jackson 2.** The security post shows:
|
||||
|
||||
```java
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
mapper.activateDefaultTyping(safeTypeValidator, ObjectMapper.DefaultTyping.NON_FINAL, ...);
|
||||
```
|
||||
|
||||
Neither half compiles against Jackson 3. `activateDefaultTyping` is on
|
||||
`JsonMapper.Builder` only, and `DefaultTyping` is now a top-level enum in
|
||||
`tools.jackson.databind` rather than nested in `ObjectMapper`.
|
||||
|
||||
## Output
|
||||
|
||||
**Jackson 2 — `jackson2-before`**
|
||||
|
||||
```
|
||||
enableDefaultTyping on Jackson 2.22 ObjectMapper : true
|
||||
written : {"@class":"com.ankurm.migration.before.S05DefaultTyping$Envelope","body":{"@class":"com.ankurm.migration.before.S05DefaultTyping$SafePayload","note":"ok"}}
|
||||
read : SafePayload[ok]
|
||||
```
|
||||
|
||||
**Jackson 3 — `jackson3-after`**
|
||||
|
||||
```
|
||||
enableDefaultTyping on Jackson 3 ObjectMapper : false
|
||||
activateDefaultTyping on Jackson 3 ObjectMapper : false
|
||||
activateDefaultTyping on JsonMapper.Builder : true
|
||||
|
||||
written : {"@class":"com.ankurm.migration.after.S05DefaultTyping$Envelope","body":{"@class":"com.ankurm.migration.after.S05DefaultTyping$SafePayload","note":"ok"}}
|
||||
read : SafePayload[ok]
|
||||
rogue : rejected with InvalidTypeIdException
|
||||
```
|
||||
|
||||
A third trap, in neither guide: with `DefaultTyping.NON_FINAL` Jackson writes a type id
|
||||
for the **root** object too. So the root class has to be in the allowlist as well as the
|
||||
payload hierarchy. Allowlist only the payload base type and the happy path fails, not
|
||||
just the attack path — which looks like a broken validator when it is working correctly.
|
||||
|
||||
The final `rogue` line is the negative test: a real class, present on the classpath,
|
||||
deserialisable in every other respect, refused at type resolution before instantiation.
|
||||
61
docs/s07-defaults-that-flipped.md
Normal file
61
docs/s07-defaults-that-flipped.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# S07 — Defaults that flipped silently
|
||||
|
||||
Guide: <https://ankurm.com/jackson-3-vs-jackson-2/> ("Removed and Changed Default Features")
|
||||
|
||||
[`before/S07DefaultsThatFlipped.java`](../jackson2-before/src/main/java/com/ankurm/migration/before/S07DefaultsThatFlipped.java) ·
|
||||
[`after/S07DefaultsThatFlipped.java`](../jackson3-after/src/main/java/com/ankurm/migration/after/S07DefaultsThatFlipped.java)
|
||||
|
||||
These are the expensive ones. No compile error, no warning — behaviour just changes.
|
||||
The two programs run identical probes so the diff is the migration surface:
|
||||
|
||||
```bash
|
||||
diff docs/output/before-S07DefaultsThatFlipped.txt \
|
||||
docs/output/after-S07DefaultsThatFlipped.txt
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
**Jackson 2 — `jackson2-before`**
|
||||
|
||||
```
|
||||
FAIL_ON_TRAILING_TOKENS : false
|
||||
FAIL_ON_UNKNOWN_PROPERTIES : true
|
||||
ALLOW_FINAL_FIELDS_AS_MUTATORS: true
|
||||
DEFAULT_VIEW_INCLUSION : true
|
||||
AUTO_DETECT_CREATORS exists : true
|
||||
|
||||
concatenated JSON -> accepted: OrderDto[orderId=1]
|
||||
trailing garbage -> accepted: OrderDto[orderId=1]
|
||||
unknown property -> rejected: UnrecognizedPropertyException
|
||||
```
|
||||
|
||||
**Jackson 3 — `jackson3-after`**
|
||||
|
||||
```
|
||||
FAIL_ON_TRAILING_TOKENS : true
|
||||
FAIL_ON_UNKNOWN_PROPERTIES : false
|
||||
ALLOW_FINAL_FIELDS_AS_MUTATORS: false
|
||||
DEFAULT_VIEW_INCLUSION : false
|
||||
AUTO_DETECT_CREATORS exists : false
|
||||
|
||||
concatenated JSON -> rejected: MismatchedInputException
|
||||
trailing garbage -> rejected: StreamReadException
|
||||
unknown property -> accepted: OrderDto[orderId=1]
|
||||
```
|
||||
|
||||
| Setting | Jackson 2 | Jackson 3 | Effect |
|
||||
|---|---|---|---|
|
||||
| `FAIL_ON_TRAILING_TOKENS` | off | **on** | Concatenated or double-encoded JSON that used to parse now throws |
|
||||
| `FAIL_ON_UNKNOWN_PROPERTIES` | **on** | off | Unknown fields are tolerated by default; strictness must be opted into |
|
||||
| `ALLOW_FINAL_FIELDS_AS_MUTATORS` | on | **off** | Jackson no longer overwrites `final` fields by reflection |
|
||||
| `DEFAULT_VIEW_INCLUSION` | on | **off** | Review any `@JsonView` usage |
|
||||
| `AUTO_DETECT_CREATORS` | present | **gone** | Constant removed — but see below |
|
||||
|
||||
The `FAIL_ON_UNKNOWN_PROPERTIES` flip is worth dwelling on because it moves in the
|
||||
*lenient* direction. Code that relied on a strict mapper to reject malformed payloads
|
||||
silently stops rejecting them after the upgrade. If you were using deserialisation
|
||||
failure as input validation, re-enable it explicitly.
|
||||
|
||||
On `AUTO_DETECT_CREATORS`: the constant is gone, but the behaviour it governed is not.
|
||||
Single-argument constructors are still detected as delegating creators — demonstrated in
|
||||
the [features repo](https://ankurm.com/git.app/asmhatre/jackson3-by-example).
|
||||
Reference in New Issue
Block a user