 Java, Spring and Web Development tutorials  1. Introduction
In this tutorial, we’ll explore how to create and configure a custom ObjectMapper for Jersey applications using Jackson. The ObjectMapper is responsible for converting Java objects to JSON and back, and by customizing it, we can control aspects like formatting, date handling, field naming conventions, and serialization rules in one place.
2. Understanding ObjectMapper
The ObjectMapper is at the core of Jackson’s functionality, responsible for turning Java objects into JSON strings and converting JSON back into Java objects. Jersey automatically integrates Jackson, so we can easily return objects from REST endpoints and get JSON responses.
While this default setup is convenient, it may not meet all our needs in real-world applications. For instance, Jackson by default writes dates as timestamps, which is not very human-readable, and it does not pretty-print JSON, which can make debugging harder.
3. JacksonJaxbJsonProvider Approach (Jersey 2.x)
In Jersey 2.x, one common way to customize Jackson is by extending the JacksonJaxbJsonProvider. This provider acts as a bridge between Jersey and Jackson, allowing us to inject our own ObjectMapper.
Using this approach, we can globally configure how JSON is serialized and deserialized across all REST endpoints in our application.
Here is a simple example of a custom provider:
@Provider
public class CustomObjectMapperProvider extends JacksonJaxbJsonProvider {
public CustomObjectMapperProvider() {
ObjectMapper mapper = new ObjectMapper();
mapper.enable(SerializationFeature.INDENT_OUTPUT);
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
setMapper(mapper);
}
}
In this provider, the ObjectMapper is configured to produce more readable JSON by enabling indentation. Dates are written as ISO-8601 strings instead of timestamps by disabling WRITE_DATES_AS_TIMESTAMPS.
In addition, the fields with null values are automatically skipped by setting the setSerializationInclusion(JsonInclude.Include.NON_NULL). We also use the PropertyNamingStrategies.SNAKE_CASE to convert Java camelCase property names into snake_case in JSON responses.
By annotating this class with @Provider and extending JacksonJaxbJsonProvider, Jersey automatically detects and registers it. Once this provider is in place, every REST endpoint in the application will use these JSON rules without additional configuration in individual resource classes.
This approach works well for Jersey 2.x applications, but it is a global configuration.
4. ContextResolver ObjectMapper Approach (Jersey 3.x)
With Jersey 3.x, the preferred way to customize Jackson is through implementing a ContextResolver<ObjectMapper>. This approach allows applications to provide multiple ObjectMapper configurations and select them conditionally based on the model class or annotation.
For example, we might want stricter rules for public API responses, while internal models include more debugging information. Using a ContextResolver makes this conditional serialization straightforward, flexible, and fully compatible with Jersey 3 and modern Jakarta EE applications.
4.1. Setup Project
To get started with Jackson in Jersey 3.x, we need to include the Jackson Jakarta RS provider in our pom.xml. This library enables JSON serialization and deserialization for Jersey endpoints:
<dependency>
<groupId>com.fasterxml.jackson.jakarta.rs</groupId>
<artifactId>jackson-jakarta-rs-json-provider</artifactId>
<version>2.19.1</version>
</dependency>
With this dependency, Jersey can use Jackson to automatically convert Java objects to JSON and parse JSON back into Java objects.
4.2. Basic ContextResolver
When our app needs different ObjectMapper settings, we use a ContextResolver<ObjectMapper> to switch between the right setup for each case.
Let’s create a simple ContextResolver example:
@Provider
public class ObjectMapperContextResolver implements ContextResolver<ObjectMapper> {
private final ObjectMapper mapper;
public ObjectMapperContextResolver() {
mapper = JsonMapper.builder()
.findAndAddModules()
.build();
mapper.enable(SerializationFeature.INDENT_OUTPUT);
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
}
@Override
public ObjectMapper getContext(Class<?> type) {
return mapper;
}
}
This ContextResolver provides a pre-configured ObjectMapper with:
- Pretty-printed JSON (INDENT_OUTPUT)
- ISO-8601 date format (WRITE_DATES_AS_TIMESTAMPS disabled)
- Skipping null fields (Include.NON_NULL)
- Snake case property names (PropertyNamingStrategies.SNAKE_CASE)
We use findAndAddModules() to automatically scan the classpath for available modules and register them with the ObjectMapper. This ensures that optional features, like Java 8 date/time support from jackson-datatype-jsr310, are automatically enabled without manual registration, making the ObjectMapper fully aware of all modules in the project.
Using a ContextResolver is flexible because we can later provide different ObjectMapper instances depending on the class type. This makes it ideal for applications with multiple API models or varying serialization rules.
4.3. Conditional ObjectMapper
In real-world applications, we often need different serialization rules for different types of objects or API contexts. For example, we might want stricter rules for public API responses while internal models include more debugging information.
A ContextResolver<ObjectMapper> can be extended to support conditional ObjectMapper configurations based on class type or annotations.
Let’s create a more complex ContextResolver that provides different ObjectMapper configurations based on the class type:
@Provider
public class ConditionalObjectMapperResolver implements ContextResolver<ObjectMapper> {
private final ObjectMapper publicApiMapper;
private final ObjectMapper internalApiMapper;
private final ObjectMapper defaultMapper;
public ConditionalObjectMapperResolver() {
publicApiMapper = JsonMapper.builder()
.findAndAddModules()
.build();
publicApiMapper.enable(SerializationFeature.INDENT_OUTPUT);
publicApiMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
publicApiMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
publicApiMapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
publicApiMapper.disable(SerializationFeature.WRITE_EMPTY_JSON_ARRAYS);
internalApiMapper = JsonMapper.builder()
.findAndAddModules()
.build();
internalApiMapper.enable(SerializationFeature.INDENT_OUTPUT);
internalApiMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
internalApiMapper.setSerializationInclusion(JsonInclude.Include.NON_EMPTY);
internalApiMapper.enable(SerializationFeature.WRITE_DATES_WITH_ZONE_ID);
defaultMapper = JsonMapper.builder()
.findAndAddModules()
.build();
defaultMapper.enable(SerializationFeature.INDENT_OUTPUT);
defaultMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}
@Override
public ObjectMapper getContext(Class<?> type) {
if (isPublicApiModel(type)) {
return publicApiMapper;
} else if (isInternalApiModel(type)) {
return internalApiMapper;
}
return defaultMapper;
}
private boolean isPublicApiModel(Class<?> type) {
return type.getPackage().getName().contains("public.api") ||
type.isAnnotationPresent(PublicApi.class);
}
private boolean isInternalApiModel(Class<?> type) {
return type.getPackage().getName().contains("internal.api") ||
type.isAnnotationPresent(InternalApi.class);
}
}
4.4. Marker Annotations
We can create simple annotations to mark which API type a model belongs to:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface PublicApi {
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface InternalApi {
}
Now, when we create model classes, those annotated with @PublicApi will use stricter rules, for example, skipping empty JSON arrays.
On the other hand, models annotated with @InternalApi will include more details for debugging purposes, such as preserving empty collections in the JSON output.
4.5. Registering the Custom ObjectMapper
After creating the ContextResolver, we need to tell Jersey to use it. This can be done by registering it in a class that extends ResourceConfig.
Here we can also specify which packages Jersey should scan for resource classes:
public class MyApplication extends ResourceConfig {
public MyApplication() {
packages("com.baeldung.model");
register(ObjectMapperContextResolver.class);
}
}
With this setup, Jersey will use our custom ObjectMapper for all REST endpoints, ensuring consistent JSON formatting across our API.
5. Testing the Custom ObjectMapper
To verify that our conditional ObjectMapper behaves as expected, we can write unit tests. These tests demonstrate that different model types are serialized according to their annotations and configuration rules.
First, let’s create test model classes:
@PublicApi
public static class PublicApiMessage {
public String text;
public LocalDate date;
public String sensitiveField;
public PublicApiMessage(String text, LocalDate date, String sensitiveField) {
this.text = text;
this.date = date;
this.sensitiveField = sensitiveField;
}
}
@InternalApi
public static class InternalApiMessage {
public String text;
public LocalDate date;
public String debugInfo;
public List<String> metadata;
public InternalApiMessage(String text, LocalDate date, String debugInfo, List<String> metadata) {
this.text = text;
this.date = date;
this.debugInfo = debugInfo;
this.metadata = metadata;
}
}
Next, we can set up the resolver for testing:
@BeforeEach
void setUp() {
ConditionalObjectMapperResolver resolver = new ConditionalObjectMapperResolver();
this.publicApiMapper = resolver.getContext(PublicApiMessage.class);
this.internalApiMapper = resolver.getContext(InternalApiMessage.class);
}
5.1. Public API Model
For models annotated with @PublicApi, we want stricter serialization rules. For example, sensitive fields should be skipped, null values should be excluded, and JSON should be formatted in snake_case:
@Test
void givenPublicApiMessage_whenSerialized_thenOmitsSensitiveFieldAndNulls() throws Exception {
PublicApiMessage message = new PublicApiMessage("Public Hello!", LocalDate.of(2025, 8, 23), null);
String json = publicApiMapper.writeValueAsString(message);
assertTrue(json.contains("text"));
assertTrue(json.contains("date"));
assertFalse(json.contains("sensitiveField"));
assertFalse(json.contains("null"));
}
This test confirms that the public API mapper enforces stricter rules for public-facing data.
5.2. Internal API Model
For models annotated with @InternalApi, we want more detailed serialization. Nulls are still excluded, but empty collections can be preserved for debugging purposes:
@Test
void givenInternalApiMessageWithEmptyMetadata_whenSerialized_thenIncludesEmptyArraysButNoNulls() throws Exception {
InternalApiMessage message = new InternalApiMessage("Internal Hello!", LocalDate.of(2025, 8, 23),
"debug-123", new ArrayList<>());
String json = internalApiMapper.writeValueAsString(message);
assertTrue(json.contains("debugInfo"));
assertFalse(json.contains("null"));
assertFalse(json.contains("metadata"));
}
If the metadata list has values, it should be serialized:
@Test
void givenInternalApiMessageWithNonEmptyMetadata_whenSerialized_thenMetadataIsIncluded() throws Exception {
InternalApiMessage message = new InternalApiMessage("Internal Hello!", LocalDate.of(2025, 8, 23),
"debug-123", Arrays.asList("meta1"));
String json = internalApiMapper.writeValueAsString(message);
assertTrue(json.contains("metadata"));
}
5.3. Default ObjectMapper
Finally, the default ObjectMapper handles models without special annotations. Optional fields and metadata are serialized normally:
@Test
void givenDefaultMessage_whenSerialized_thenIncludesOptionalFieldAndMetadata() throws Exception {
Message message = new Message("Default Hello!", LocalDate.of(2025, 8, 23), "optional");
message.metadata = new ArrayList<>();
String json = defaultMapper.writeValueAsString(message);
assertTrue(json.contains("metadata"));
assertTrue(json.contains("optionalField") || json.contains("optional"));
}
We also check that dates are serialized in ISO-8601 format:
@Test
void givenMessageWithDate_whenSerialized_thenDateIsInIso8601Format() throws Exception {
Message message = new Message("Date Test", LocalDate.of(2025, 9, 2), "optional");
String json = defaultMapper.writeValueAsString(message);
assertTrue(json.contains("2025-09-02"));
}
6. Conclusion
In this tutorial, we explored customizing Jackson’s ObjectMapper for Jersey applications. For Jersey 2.x, extending JacksonJaxbJsonProvider allows us to globally configure JSON serialization, ensuring consistent formatting all endpoints. This approach is simple but applies the same rules to all models.
For Jersey 3.x, implementing a ContextResolver<ObjectMapper> provides greater flexibility. We can define multiple ObjectMapper configurations and select them conditionally based on annotations or model types. This approach ensures flexible, maintainable, and consistent JSON handling in modern Jersey applications.
As always, the source code is available over on GitHub. The post Custom ObjectMapper with Jersey and Jackson first appeared on Baeldung.
Content mobilized by FeedBlitz RSS Services, the premium FeedBurner alternative. |