1
0

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:
2026-08-04 23:12:25 +05:30
commit f0a7053fc9
43 changed files with 1665 additions and 0 deletions

15
docs/README.md Normal file
View 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
View 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.

View 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

View 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

View 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

View 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}

View 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

View 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]

View 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

View File

@@ -0,0 +1,3 @@
JsonProcessingException extends IOException : true
caught by catch (IOException) : JsonParseException
writeValueAsString declares : [class com.fasterxml.jackson.core.JsonProcessingException]

View 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

View 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

View 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]

View 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

View 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

View 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.

View 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`).

View 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.

View 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.

View 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.

View 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.

View 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).