Skip to main content

Using Jackson 3

LangChain4j reads and writes JSON in a lot of places: the requests and responses it exchanges with LLM providers, the structured output an AI Service parses, chat memory you persist, and more. By default it does that with Jackson 2. How LangChain4j uses JSON, and how to plug in your own mapper, is covered in JSON.

If your application is on Jackson 3, you can have LangChain4j use it instead.

Turning it on​

Add one dependency:

<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-jackson3</artifactId>
<version>1.22.0-beta32</version>
</dependency>

That is the whole of it, for every module including langchain4j-agentic. LangChain4j finds the module through the ServiceLoader and routes its JSON through Jackson 3. There is nothing to configure and no API to call, and if you remove the dependency everything goes back to Jackson 2.

The support is split across two artifacts, and langchain4j-jackson3 above pulls in both, so that is the one to add unless you have a reason not to:

ArtifactCoversDepends on
langchain4j-core-jackson3everything in langchain4j-core: provider requests and responses, structured output, chat-memory serialization, agent statelangchain4j-core
langchain4j-jackson3the above, plus InMemoryEmbeddingStore persistence, which lives in the langchain4j modulelangchain4j-core-jackson3 and langchain4j

Add langchain4j-core-jackson3 on its own only if your application uses langchain4j-core and a provider without the langchain4j module - for instance a framework integration that builds models directly. Adding it alongside langchain4j-jackson3 is unnecessary but harmless.

What stays the same​

Switching a JSON library is a good way to change behaviour by accident, so the module works hard not to. Jackson 3 changed several defaults, and almost every one that changes what LangChain4j reads or writes is set back to what Jackson 2 did. The few kept on purpose are listed under What is deliberately different.

SettingJackson 3 defaultWhat this module does
ALLOW_FINAL_FIELDS_AS_MUTATORSdisabledenabled
USE_GETTERS_AS_SETTERSdisabledenabled
SORT_PROPERTIES_ALPHABETICALLYenableddisabled
FAIL_ON_TRAILING_TOKENSenableddisabled
FAIL_ON_NULL_FOR_PRIMITIVESenableddisabled
READ_ENUMS_USING_TO_STRINGenableddisabled: an enum is read by name()
WRITE_ENUMS_USING_TO_STRINGenableddisabled: an enum is written by name()
DETECT_PARAMETER_NAMESenableddisabled, except for structured output and tool arguments (see below)
FAIL_ON_UNKNOWN_PROPERTIESdisabledenabled: a field the target type does not have fails in structured output, tool arguments, chat memory, agent state and stored embedding stores; provider responses still ignore unknown fields
FAIL_ON_EMPTY_BEANSdisabledenabled: an object with nothing to write - for example one whose fields are private and have no getters - fails instead of being sent as {}

The first one matters most: without it, a final collection field is left empty instead of being populated, and nothing tells you.

DETECT_PARAMETER_NAMES only matters when your code is compiled with -parameters, as Spring Boot and Quarkus projects usually are. For structured output and tool arguments, constructor parameter names are used, as the Jackson 2 codec does when jackson-module-parameter-names is on the classpath, which it is in Spring Boot and Quarkus: a class with only a constructor with arguments, such as a Lombok @AllArgsConstructor class, can be read. Chat memory, agent state and the other codecs do not use parameter names, as with Jackson 2.

To choose a constructor explicitly, whatever the compiler settings, annotate it:

  • with @JsonCreator(mode = JsonCreator.Mode.DELEGATING) to read a plain value such as "abc" through a one-argument constructor;
  • with @JsonCreator, and each parameter with @JsonProperty("name"), to read an object through a constructor with arguments.

Both work with either Jackson version.

What is deliberately different​

A few differences remain on purpose, because restoring them would cost more than it gives:

  • Dates and times are written as ISO-8601 strings. java.util.Date and Calendar become "1970-01-01T00:00:00.000Z" instead of epoch milliseconds. With jackson-datatype-jsr310 on the classpath, as in Spring Boot and Quarkus applications, Jackson 2 also writes Instant, OffsetDateTime and Duration as numbers; this module writes them as ISO-8601 strings, which an LLM reads far more reliably. Numbers are still read for Date, Instant and Duration.
  • java.time.Month is written as a number starting at 1 (1 for January). With jackson-datatype-jsr310, Jackson 2 writes "JANUARY" and reads a number as a position starting at 0, so 1 is FEBRUARY. Both versions read the name.
  • "" is read as null for an enum, the same as a missing value. This applies to required enums too: like a missing field, "" gives null rather than an error. Jackson 2 fails instead, but providers send it - an OpenAI-compatible server returning "type": "" for a tool call is what found this - and an LLM may answer "" for an optional enum. "" for an object, a map or a list still fails, as it does with Jackson 2.
  • A private one-argument constructor is not used to read a plain value. Jackson 2 uses it; with Jackson 3, annotate it with @JsonCreator(mode = JsonCreator.Mode.DELEGATING).
  • For structured output and tool arguments, a class with both a no-argument constructor and a constructor with arguments is created through the one with arguments when it is compiled with -parameters. Jackson 2 uses the no-argument constructor and then sets the fields. This only matters if that constructor does more than assign fields.
  • A field such as xValue with a getter getXValue() is written as "xValue". Jackson 2 derives "xvalue" from the getter: for structured output, tool results and agent state it writes both "xValue" and "xvalue", and for provider requests only "xvalue". Agent state written by Jackson 2 with such a duplicate key fails to read with this module.

Failures get a LangChain4j type. By default, a JSON failure surfaces as a RuntimeException wrapping Jackson 2's own exception - which means code that reacts to it has to know Jackson 2. With this module, reading or writing JSON that fails throws JsonReadException or JsonWriteException instead, both LangChain4jException, with the library's exception kept as the cause.

That is a deliberate step rather than an inconsistency: the typed exceptions are where LangChain4j is going in the next major version, and the Jackson 2 codecs stay as they are until then so that existing code keeps working. If you catch a JSON failure by its Jackson type, that is the one thing to revisit when you add this module:

- } catch (JsonParseException e) {
+ } catch (JsonReadException e) {

Catching RuntimeException works either way.

Two places reach application code without a catch block being involved, so they are worth checking before you switch:

A tool-argument error handler. When a model produces tool arguments that are not valid JSON, LangChain4j hands the failure to the handler you registered with AiServices.toolArgumentsErrorHandler(...). On Jackson 2 that failure is Jackson's own JsonParseException; with this module it is a JsonReadException. A handler that branches on the type stops matching, and nothing warns you, because the handler is still called and simply takes its other branch:

AiServices.builder(Assistant.class)
.toolArgumentsErrorHandler((error, context) -> {
- if (error instanceof JsonParseException) {
+ if (error instanceof JsonReadException) {
return ToolErrorHandlerResult.text("Please return valid JSON.");
}
throw error;
})

Match on the message, or on RuntimeException, if you want a handler that works under both.

The cause of an OutputParsingException. When structured output cannot be parsed, LangChain4j throws OutputParsingException either way - that type does not change. What changes is the exception underneath it, from Jackson 2's JsonProcessingException to Jackson 3's StreamReadException. Code that inspects getRootCause() needs the same treatment.

Data you have already stored stays readable. Chat memory and InMemoryEmbeddingStore files written by Jackson 2 are read correctly by Jackson 3, and what Jackson 3 writes is byte-for-byte what Jackson 2 would have written. That is covered by tests, so it stays true.

Removing Jackson 2​

Adding the module does not by itself remove Jackson 2 - it still arrives as a normal transitive dependency, and the two can coexist indefinitely. If you want it gone, you can usually have that, because LangChain4j no longer loads any Jackson 2 class once this module is present.

Whether it can actually leave depends on which modules you use:

ModuleCan Jackson 2 be excluded?
langchain4j-core, langchain4jYes — they ship Jackson 2 codecs, but those are the fallback and are never loaded while this module is on the classpath
Provider modules — OpenAI, Anthropic, Mistral, Ollama, Gemini, and the restYes
Embedding stores, web search, code executionYes
langchain4j-agenticYes
langchain4j-guardrailsNo - JsonExtractorOutputGuardrail parses with a plain Jackson 2 ObjectMapper on purpose, so that a guardrail stays as strict as it was
langchain4j-mcpNo — Jackson's JsonNode appears in its public API, so the module loads Jackson 2 on every path
langchain4j-vespaNo — its HTTP client uses Retrofit's own Jackson 2 converter
Anything using a vendor SDK — AWS, Azure, GoogleNo — the SDK depends on Jackson 2 itself

Maven exclusions apply to the dependency they are written on, so they go on every LangChain4j dependency you declare, not only the first:

<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
<version>1.22.0</version>
<exclusions>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</exclusion>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- and the same on langchain4j-open-ai, and on any other LangChain4j module you use -->

Do not exclude com.fasterxml.jackson.core:jackson-annotations. It keeps the 2.x coordinates but is shared: Jackson 3 depends on it and reads those annotations.

This configuration is not just believed to work, it is built and run on every pull request: integration-tests/integration-tests-jackson3 is an application assembled exactly this way - core, the main module, a provider, agents and this module, with Jackson 2 excluded from the whole graph - and its tests cover AI Services structured output, tool calling, chat-memory persistence, embedding-store persistence and agent-state persistence. If any of that started needing Jackson 2, those tests would fail to find the class.

If you use MCP, Vespa, or a cloud SDK, Jackson 2 stays. The opt-in still gives you a single Jackson 3 code path for everything LangChain4j itself serializes.

If you write a provider or a DTO​

Two things to know if you contribute a wire type.

Naming belongs on the codec, not on the type. @JsonNaming lives in Jackson 2's databind package, so Jackson 3 does not see it at all — the field names silently come out camelCase. Set the naming on the codec instead:

ProviderJson.codec(ProviderJsonSpec.builder()
.propertyNaming(ProviderJsonSpec.PropertyNaming.SNAKE_CASE)
.build());

If a single field needs a different name, @JsonProperty("...") works under both, because it comes from jackson-annotations, the artifact the two versions share.

A builder-based DTO needs @JsonCreator and field visibility on its builder. @JsonDeserialize(builder = ...) is also a databind annotation, so under Jackson 3 the DTO is instead built through the @JsonCreator on the constructor that takes the builder. Jackson 3 fills that builder by writing its private fields, so the builder also needs @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY). Without it, the response parses without an error but the fields stay null. All three have to be present.

Whether a builder method runs depends on the annotations on it. This is the part to internalise, because it is silent and the rule is not the one you would guess. Jackson 2 fills a builder by calling its setters and then build(). Jackson 3 calls a setter only when it carries a property marker, writes the field directly otherwise, and never calls build() at all:

On a builder setterJackson 2Jackson 3
@JsonSetter("odd-name"), @JsonProperty, bare @JsonSettercalledcalled
@JsonAlias on its owncallednot called - the alias is ignored
no annotationcallednot called - the field is written directly
build()callednever called

So the two codecs can diverge property by property within one type, which is why this is worth a rule rather than vigilance:

Put what the type guarantees where both routes go through it. A default belongs on the builder's field, not in build(). A defensive copy or any normalising belongs in the constructor, not in a setter. An alias needs @JsonAlias on the builder's field as well as its setter.

Each of those went wrong here: a type defaulted in build() dropped every mistral-ai tool call; @JsonAlias on a setter dropped reasoning content from vLLM, OpenRouter and Groq; and unmodifiableList(...) in an unannotated setter meant the same response came back mutable under one codec and not the other. None of them threw.

A module that supports the opt-in declares a jackson3 Maven profile, which puts langchain4j-jackson3 on that module's test classpath so its existing tests run against Jackson 3. CI runs all of them on every pull request, and you can run one module the same way:

mvn test -Pjackson3 -pl langchain4j-your-module

Check that the module's pom.xml actually declares that profile before trusting the result: Maven ignores a profile the selected module does not declare, so the command above reports success having run everything on Jackson 2. Adding the profile is part of migrating a module.

The profile puts langchain4j-core-jackson3 on the test classpath, not langchain4j-jackson3, because the latter depends on the langchain4j module and would make the graph cyclic for the modules that module is itself built on. InMemoryEmbeddingStore persistence, which is the part that needs the full artifact, is covered by langchain4j-jackson3's own tests and by integration-tests/integration-tests-jackson3.

langchain4j-open-ai also carries OpenAiBuilderCreatorParityTest, which compares every builder-based DTO built through its builder against the same DTO parsed from {}. That is the difference the missing build() call above produces, so the test catches it for the whole of the OpenAI wire model at once rather than one field at a time. The same test also fails when a builder-based DTO is missing the @JsonCreator or its builder is missing @JsonAutoDetect.

If you plug in your own JSON​

You do not need this section to use Jackson 3 — adding the dependency is enough. It is for frameworks that supply their own configured JSON mapper to LangChain4j rather than letting it pick one, which is what langchain4j-jackson3 itself does.

LangChain4j does not have a single JSON entry point. It asks a ServiceLoader for a codec at each of the places below, so that each can be answered separately:

Service interfaceDecides how LangChain4j reads and writes
dev.langchain4j.spi.json.JsonCodecFactorygeneral-purpose JSON — an AI Service's structured output, a model's tool arguments
dev.langchain4j.spi.json.ProviderJsonCodecFactorythe requests and responses exchanged with LLM providers
dev.langchain4j.spi.json.StateJsonCodecFactorystate whose types are not known ahead of time, and which therefore carries type names — agent state
dev.langchain4j.spi.data.message.ChatMessageJsonCodecFactorychat memory you persist
dev.langchain4j.spi.agent.tool.ToolSpecificationJsonCodecFactoryToolSpecification.toJson() and ToolSpecification.fromJson(String)
dev.langchain4j.spi.prompt.structured.StructuredPromptFactory@StructuredPrompt templates
dev.langchain4j.spi.store.embedding.inmemory.InMemoryEmbeddingStoreJsonCodecFactoryInMemoryEmbeddingStore.serializeToJson()

The last one lives in langchain4j; the rest live in langchain4j-core. All are @Internal, which here means they are meant for integrations rather than applications, and can change between minor versions.

A framework's own implementation wins. If something already supplies one of these - Quarkus supplies four - adding this module does not take it away. The Jackson 3 factories declare a lower priority than anything else, so they apply only to the services nothing else has claimed. This happens silently: no warning is logged, because the priority decides which implementation is used. That means on such a framework the opt-in is partial by design: provider traffic and agent state move to Jackson 3 while the services the framework owns stay on its own codec. If you want the whole application on Jackson 3, remove the framework's registrations.

Implement all of them, or know which you are leaving out. Each is resolved independently, and one with no implementation registered falls back to Jackson 2. Answering some but not others is not an error and produces no warning — it produces an application where, say, chat memory is written by your mapper and agent state by a different library. If you deliberately leave one out, the fallback is what you get.

Two of these are worth a second look if you already integrate with an older version: ProviderJsonCodecFactory and StateJsonCodecFactory are new, so an existing integration that does not know about them keeps working while quietly using Jackson 2 for provider traffic and agent state.