Jackson 3 Migration logo

Jackson 3 Migration

Organization
navikt
jackson-3-migration

Migrer Jackson 2.x til Jackson 3.x (tools.jackson) i Kotlin/Java-prosjekter — automatisert OpenRewrite-pass pluss manuell Kotlin-spesifikk opprydding og verifisering

Overview

Publishernavikt
Repositorycopilot
Skill namejackson-3-migration
Stars
60
Forks
16
Bundled files
4
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • 4 bundled files

    Scripts, templates, and references the model can read while it works. Files are read-only and never executed.

  • Open source

    Published by navikt on GitHub. Read the source before you install it.

Installation

Install the Jackson 3 Migration AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/navikt/copilot/tree/main/skills/jackson-3-migration
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/navikt/copilot.git /tmp/copilot
mkdir -p .claude/skills
cp -r /tmp/copilot/skills/jackson-3-migration .claude/skills/jackson-3-migration
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Jackson 3 Migration in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Jackson 3 Migration on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Jackson 3 Migration is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

Jackson 2 → 3 Migration

Systematic migration from Jackson 2.x (com.fasterxml.jackson) to Jackson 3.x (tools.jackson). Combines an automated OpenRewrite pass for mechanical Java changes with explicit manual steps for Kotlin-specific patterns that OpenRewrite does not cover, followed by build+test verification.

When to Use

  • A Nav service depends on com.fasterxml.jackson.* and needs to move to Jackson 3.x (e.g. for Spring Boot 4 compatibility)
  • Build fails after a transitive dependency bump pulls in Jackson 3
  • Recurring "fix Jackson migration" requests — use this skill instead of re-deriving the rules each time

Pre-flight Checks

  1. Confirm baseline JDK is 17+ (java -version, sourceCompatibility in Gradle). Jackson 3 does not run on Java 8/11 — stop and flag if not met.
  2. Confirm the frameworks that actually carry Jackson 3 support are in place first: Spring Boot 4.0+ (Boot 3.x is Jackson 2 only) and Ktor 3.4.0+ (required for ktor-serialization-jackson3).
  3. Check for internal version skew — a hardcoded version string elsewhere in the build can contradict the version catalog, and the catalog may not be a local file you can grep. Ask Gradle: ./gradlew dependencyInsight --dependency <artifact> --configuration compileClasspath.
  4. Target Jackson 3.1+ (LTS). Jackson 3.0.x is a transitional, non-LTS release — avoid pinning to it if 3.1+ is available.
  5. After migrating, confirm no stray Jackson 2.x databind/core remains on the classpath (./gradlew dependencies --configuration compileClasspath | grep jackson) — a leftover 2.x jar lets old com.fasterxml.jackson.databind.* imports keep compiling with no error.
  6. If the service uses Ktor content negotiation, swap ktor-serialization-jackson for ktor-serialization-jackson3 (package io.ktor.serialization.jackson3.*, jackson3 { ... } DSL) — the old artifact stays on Jackson 2 and is not drop-in compatible.

Failure modes, exact commands and reasoning for steps 2, 3, 5 and 6: references/dependency-preflight.md.

Package/Group-ID Exception (apply before any blanket rename)

com.fasterxml.jackson → tools.jackson is not a universal find-replace:

  • jackson-annotations (group-id, and com.fasterxml.jackson.annotation.* package) stays on the old name — it is still versioned as a 2.x artifact (e.g. jackson-annotations:2.20) and used as-is by Jackson 3.
  • Exception to the exception: databind-level annotations like @JsonSerialize/@JsonDeserialize, and format-specific annotations (e.g. XML ones), do move to tools.jackson.databind.annotation / the corresponding new package.
  • It is correct and expected for a fully migrated Jackson 3 codebase to still import com.fasterxml.jackson.annotation.* (for @JsonProperty, @JsonIgnore, etc.) alongside tools.jackson.* imports elsewhere. Do not "fix" these as if they were missed renames.

Prefer semantic tools over text search-and-replace for this step. Before renaming, use LSP/code-intelligence tools (findReferences, goToDefinition) or an IDE's MCP server (e.g. search_symbol, rename_refactoring) to enumerate every real usage of a Jackson type — a blind grep+sed pass cannot tell com.fasterxml.jackson.annotation.* (stays) apart from com.fasterxml.jackson.databind.* (moves), and will happily "rename" a string literal or comment that only looks like a package path. After renaming, re-run findReferences/symbol search to confirm no stale com.fasterxml.jackson.* symbol remains outside the annotation exception.

Don't guess a 3.x subpackage by analogy with 2.x layout, and don't unzip jars to check. Most com.fasterxml.jackson.databind.* types land in the tools.jackson.databind root package in 3.x (e.g. DatabindException, not tools.jackson.databind.exc.DatabindException) — see references/rename-and-defaults.md for a verified list of fully-qualified paths. If a class isn't in that list, verify the package with LSP workspaceSymbol search or IDE autocomplete before trying an import.

Step 1: Automated Pass (OpenRewrite)

Use the official recipe to handle mechanical Java renames before touching anything by hand.

Check the current state before assuming a clean 2.x baseline. If the codebase already has a partial, hand-rolled migration attempt (mixed com.fasterxml/tools.jackson imports, ad-hoc renames), running the recipe may not apply cleanly or may not be the fastest path. In that case, run ./gradlew compileKotlin (or the Java equivalent) directly first to see the actual current compile errors, then work from those rather than assuming this playbook's recipe-first order fits as-is.

kotlin
// build.gradle.kts — add temporarily if not already present
plugins {
    id("org.openrewrite.rewrite") version "<latest>"
}
dependencies {
    rewrite("org.openrewrite.recipe:rewrite-jackson:<latest>")
}
bash
./gradlew rewriteRun --recipe=org.openrewrite.java.jackson.UpgradeJackson_2_3

For Maven projects, use the rewrite-maven-plugin equivalent instead:

bash
mvn org.openrewrite.maven:rewrite-maven-plugin:run \
  -Drewrite.activeRecipes=org.openrewrite.java.jackson.UpgradeJackson_2_3
  • Review the diff — OpenRewrite handles Java import/package renames (com.fasterxml.jackson.* → tools.jackson.*, respecting the jackson-annotations exception above), some deprecated-API replacements, and Maven/Gradle group-id/dependency-coordinate updates for Java sources.
  • It does not reliably rewrite Kotlin source files — treat its output as the Java-side baseline, not the finish line.
  • Run a build immediately after the recipe (before doing manual cleanup) — this surfaces removed/renamed APIs the recipe didn't catch as compile errors early, rather than mixing them with behavioral cleanup later.
  • Remove the OpenRewrite plugin/dependency again once the recipe has run, unless you want to keep it for future recipes.

Step 2: Kotlin-Specific Cleanup (manual — OpenRewrite does not cover this)

Nav services are predominantly Kotlin. These changes must be applied by hand. See references/kotlin-cleanup.md for the full set of before/after snippets (mutable-ObjectMapper patterns, date/timezone config, visibility config, polymorphic typing, JsonFactory builder, @JsonView).

jackson-module-kotlin

kotlin
// build.gradle.kts — before
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.x")

// after
implementation("tools.jackson.module:jackson-module-kotlin:3.x")
kotlin
// before
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
val mapper = ObjectMapper().registerKotlinModule()

// after — package changes, and ObjectMapper is now immutable/builder-based
import tools.jackson.databind.json.JsonMapper
import tools.jackson.module.kotlin.jacksonObjectMapper
val mapper = jacksonObjectMapper()

// need further config? prefer jacksonMapperBuilder() over manually chaining
// JsonMapper.builder().addModule(kotlinModule()) — same result, one call:
import tools.jackson.module.kotlin.jacksonMapperBuilder
val mapper = jacksonMapperBuilder()
    .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
    .build()

Search for ObjectMapper() followed by .apply, .registerModule, .configure, .enable, .disable, .setXxx calls outside a builder chain — these are the highest-risk pattern in Kotlin codebases and fail silently rather than with a compile error (full before/after in the reference file).

JsonNode.map() shadows Kotlin's Iterable.map (Jackson 3.1+)

The most widespread Kotlin breakage in this migration — and one OpenRewrite does not touch. Jackson 3.1 added a member method on JsonNode (jackson-databind#5579):

java
public <R> R map(Function<? super JsonNode, ? extends R> mapper) { return mapper.apply(this); }

JsonNode still implements Iterable<JsonNode>, and Kotlin resolves members before extensions, so existing jsonNode.map { ... } stops calling kotlin.collections.map (iterate children → List<R>) and instead calls the new member (apply the lambda once to the node itself → a single R). Method references (node.map(JsonNode::asString)) are SAM-converted onto the member just the same.

Fix by going through values() — the same children view JsonNode.iterator() uses, so no copy and identical semantics to the old behavior:

kotlin
// before (Jackson 2, or Jackson 3.0.x): List<String>. On 3.1+: one String.
val behov = packet["@behov"].map { it.asString() }

// after
val behov = packet["@behov"].values().map { it.asString() }

// null-safe chains need the conversion inside the chain
val orgnumre = godkjenning["orgnummere"]?.values()?.map(JsonNode::asString).orEmpty()
  • Usually, but not always, a compile error. It typically surfaces as an Unresolved reference / type mismatch on the next call in the chain (.containsAll(...), .sorted()). It compiles silently — and ships a behavior change — when the single result happens to fit: e.g. the lambda returns String and the chain continues with CharSequence extensions (first(), take(), filter { }), or the result is only interpolated into a string or passed as Any.
  • Only map is affected. filter, filterNot, flatMap, mapNotNull, any, none, first have no JsonNode member and still resolve to the Kotlin extensions — including on a List produced by an earlier filter. forEach resolves to java.lang.Iterable.forEach(Consumer), which behaves identically.
  • The asIterable() workaround suggested in the issue thread does not exist as a Kotlin stdlib extension on Iterable.
  • A project pinned to Jackson 3.0.x won't see this at all; it appears the moment the version is bumped to 3.1+.

How to find every call site — including the silently-compiling ones — is in references/kotlin-cleanup.md.

Nav-specific gotcha: fields/properties starting with æ, ø, å

Extremely sneaky, and silent. Jackson's default accessor-naming validator treats æ/ø/å as a non-letter first character, so Kotlin properties/Java fields starting with one (common in Norwegian domain models: årsak, ønsket) are rejected as getter/setter targets and dropped from (de)serialization with no exception and no warning. Not new in Jackson 3 — but very likely to resurface exactly when an ObjectMapper/JsonMapper config gets rebuilt as a builder chain.

kotlin
val mapper = JsonMapper.builder()
    .accessorNaming(
        DefaultAccessorNamingStrategy.Provider()
            .withFirstCharAcceptance(true, true) // lower-case ok, non-ASCII-letter ok
    )
    .build()

Add a regression test asserting that a field starting with æ/ø/å round-trips — this bug class never shows up as a compile error or in a "does it crash" test.

Step 3: General Manual Cleanup

Exceptions are now unchecked (JsonProcessingException → JacksonException extends RuntimeException), several class/method renames apply to jackson-databind and the streaming API, and a number of defaults changed silently (dates as ISO-8601 strings instead of epoch millis, alphabetical property sorting, enums via toString(), etc.). These do not surface as compile errors — they change runtime behavior and typically show up as test failures. See references/rename-and-defaults.md for the full rename tables and the complete list of changed defaults.

kotlin
// jackson-bom — recommended to avoid version-skew across modules
dependencies {
    implementation(platform("tools.jackson:jackson-bom:3.1.0"))
    implementation("tools.jackson.core:jackson-databind")
    implementation("tools.jackson.module:jackson-module-kotlin")
}

Step 4: Verification

  1. ./gradlew build (or project's equivalent) — compile errors will surface most renamed classes/methods immediately.
  2. Run the full test suite — immutable ObjectMapper misconfiguration and default-setting changes (e.g. FAIL_ON_TRAILING_TOKENS now on by default) typically show up as test failures, not compile errors.
  3. Check for JsonNode.map shadowing (Jackson 3.1+): source-search every jsonNode.map { } call site, then confirm with javap -p -c over build/classes/kotlin that no call to …databind/*Node.map: remains — match every *Node type, not just JsonNode, since the call site records the receiver's static type. A compiling build does not prove this one is clean. Procedure in references/kotlin-cleanup.md.
  4. Grep for any remaining com.fasterxml.jackson imports outside jackson-annotations usage — these indicate incomplete migration.
  5. Re-run the dependency-tree check from Pre-flight step 5 — grepping your own source is not enough, since a stray transitive com.fasterxml.jackson.core:jackson-databind can let old imports keep compiling without ever showing up in a source-level grep.
  6. If default-setting changes break existing behavior intentionally relied upon, consider JsonMapper.builderWithJackson2Defaults() as a stepping stone rather than reintroducing legacy settings ad hoc.

For symptom → likely cause → fix lookups (e.g. "dates serialize as strings now", "property order changed"), see references/rename-and-defaults.md.

Related

ResourceUse For
java-to-kotlin skillBroader Java→Kotlin conversion if migrating both at once
kotlin-app-config skillSealed class config pattern, useful when rebuilding ObjectMapper setup as a builder
OpenRewrite recipe org.openrewrite.java.jackson.UpgradeJackson_2_3Automated mechanical Java-side migration
Official migration guideAuthoritative source — consult for anything not covered here
jackson-databind#5579The JsonNode.map() addition (3.1) and the Kotlin shadowing discussion in its comments

Boundaries

✅ Always

  • Verify Java 17+ baseline before starting
  • Verify framework minimums (Ktor 3.4.0+, Spring Boot 4.0+) and check for hardcoded-vs-catalog version skew with ./gradlew dependencyInsight --dependency <lib>
  • Run the OpenRewrite recipe first, then do Kotlin-specific cleanup by hand
  • Search explicitly for post-construction ObjectMapper mutation (.apply { ... } patterns) — the #1 silent-failure risk
  • Rewrite every jsonNode.map { ... } / jsonNode.map(JsonNode::asX) as .values().map { ... } on Jackson 3.1+ — the new JsonNode.map() member shadows Kotlin's Iterable.map — and verify with a javap bytecode grep, since some cases compile silently
  • Run a dependency-tree check (./gradlew dependencies | grep jackson) after migrating — old imports keep compiling with no error if a stray Jackson 2.x jar is still on the classpath
  • Use LSP/symbol tools (findReferences, rename refactoring) to verify Jackson usages before and after renaming, not blind text search-and-replace
  • Run full build + test suite after migration, not just compile
  • Target Jackson 3.1+ (LTS), not 3.0.x
  • Verify accessorNaming handles æ/ø/å-prefixed properties when rebuilding any ObjectMapper/JsonMapper config — add a round-trip test, don't just trust that it "still works"
  • Swap ktor-serialization-jackson for ktor-serialization-jackson3 (and its io.ktor.serialization.jackson3.* import) together with the Jackson 3 bump, if the project uses Ktor content negotiation

⚠️ Ask First

  • Removing the OpenRewrite plugin vs. keeping it in the build for future use
  • Whether to adopt jackson-bom if the project doesn't already pin Jackson versions centrally
  • Reintroducing Jackson 2.x default behavior via JsonMapper.builderWithJackson2Defaults() instead of adapting to new defaults

🚫 Never

  • Assume OpenRewrite fully handles Kotlin source files — it doesn't
  • Assume a green build means the Kotlin side is done — jsonNode.map { } can bind to the new JsonNode.map() member and compile cleanly while returning a single value instead of a list
  • Blanket-rewrite com.fasterxml.jackson.annotation.* to tools.jackson — that package intentionally stays on the old name; only databind/format-specific annotations move
  • Leave com.fasterxml.jackson imports referring to non-annotation packages (databind, core, datatype, etc.) after migration is declared done — those must all move to tools.jackson
  • Migrate Jackson before confirming all dependent libraries (Spring, Ktor, etc.) support Jackson 3
  • Treat enableDefaultTyping() as something to find a replacement for — it's removed by design for security; redesign around validated activateDefaultTypingAsProperty(...) instead

Bundled files

The model reads these on demand while the skill is loaded. They are exposed as readable files and are never executed.

Frequently asked questions

What does the Jackson 3 Migration AI skill do?

Migrer Jackson 2.x til Jackson 3.x (tools.jackson) i Kotlin/Java-prosjekter — automatisert OpenRewrite-pass pluss manuell Kotlin-spesifikk opprydding og verifisering

Why use Jackson 3 Migration on TypingMind?

Because you install it once and use it with any model. Jackson 3 Migration is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Jackson 3 Migration in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/navikt/copilot/tree/main/skills/jackson-3-migration. TypingMind reads its SKILL.md and bundles its files and installs it as a skill you can enable per chat.

Which AI models can use Jackson 3 Migration?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Jackson 3 Migration?

As many as you like. As long as a model supports skills, you can use Jackson 3 Migration with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Jackson 3 Migration AI skill free?

Yes. It is published on GitHub by navikt under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇