Skip to main content

5 Jackson Configuration Changes That Silently Break Your Microservices

· 9 min read
Evgenii Frolikov
Senior Java Architect | Expert in High-Load Systems & JVM Internals

5 Jackson Configuration Changes That Silently Break Your Microservices

TL;DR: Your service compiles. Your unit tests pass. Your integration tests are green. But a single line in your ObjectMapper configuration just changed what every outgoing HTTP request looks like. The downstream service cannot parse the payload anymore, and you will find out in production. Here are five Jackson configuration changes that cause this, with exact before/after JSON for each.

Why Jackson Is the Most Dangerous Dependency in Your Stack

Jackson is everywhere. If you run Spring Boot, Jackson serializes every @RestController response, every Feign client request, every Kafka message with a JSON payload, and every Redis value stored as JSON.

This makes ObjectMapper configuration one of the most impactful settings in a microservice. A single property change can alter the serialized output of every outgoing HTTP call in the service. And because the change happens at the serialization layer, it is invisible to:

  • Unit tests that mock the HTTP client (never see the serialized bytes)
  • Contract tests that check field presence but not exact format
  • The compiler (the Java objects are unchanged)
  • The developer (the diff shows a one-line config change, not a payload break)

The result: the most common cause of "it worked yesterday" in microservices is not a logic bug. It is a serialization change.

1. WRITE_DATES_AS_TIMESTAMPS Turns ISO Strings Into Numbers

This is the single most common Jackson-related production incident in Spring Boot applications.

The config change:

objectMapper.configure(
SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true
);

Or equivalently in application.yml:

spring:
jackson:
serialization:
write-dates-as-timestamps: true

Before:

{
"createdAt": "2026-02-28T14:30:00.000Z",
"expiresAt": "2026-03-28T14:30:00.000Z"
}

After:

{
"createdAt": 1772316600000,
"expiresAt": 1774908600000
}

The downstream service expects an ISO-8601 string. It receives a Unix timestamp as a number. DateTimeParseException in production.

Why tests miss it: Unit tests that mock the HTTP client never see the serialized JSON. They work with Java Instant or LocalDateTime objects, which are unchanged. The contract test checks that createdAt exists and is not null. It does not check the format.

How common is this? Extremely. Spring Boot's default depends on whether JavaTimeModule is registered and which version of jackson-datatype-jsr310 is on the classpath. A Spring Boot minor version bump can flip this behavior.

2. Include.NON_NULL Makes Fields Disappear

The config change:

objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);

Before (field present with null value):

{
"customerId": "42",
"loyaltyTier": "GOLD",
"referralCode": null
}

After (field completely absent):

{
"customerId": "42",
"loyaltyTier": "GOLD"
}

The downstream service processes the payload. When referralCode is null, it clears the existing referral. When referralCode is absent, it keeps the old value. These are different business outcomes.

Why tests miss it: The Java object has referralCode = null in both cases. Any test that asserts on the Java object sees the same result. Only the serialized JSON is different.

The trap: This is often introduced as a "clean up" or "reduce payload size" optimization. The pull request looks harmless: one line, no logic change, no test failures.

3. Enum Serialization Strategy Changes Strings to Integers

The config change:

objectMapper.configure(
SerializationFeature.WRITE_ENUMS_USING_INDEX, true
);

Or by adding @JsonValue on an enum method, or switching from @JsonFormat(shape = STRING) to default.

Before:

{
"orderId": "ORD-789",
"status": "PROCESSING",
"priority": "HIGH"
}

After:

{
"orderId": "ORD-789",
"status": 1,
"priority": 2
}

The downstream service does OrderStatus.valueOf(jsonNode.get("status").asText()). It receives "1" instead of "PROCESSING". IllegalArgumentException.

The variant: Even without WRITE_ENUMS_USING_INDEX, reordering enum constants changes ordinal values. If any downstream service stores or compares enums by ordinal, the behavior silently changes.

Why tests miss it: The Java enum is still OrderStatus.PROCESSING. No logic changed. The test asserts assertEquals(OrderStatus.PROCESSING, order.getStatus()) and it passes.

4. BigDecimal Serializes as Number Instead of String

The config change:

Removing @JsonFormat(shape = JsonFormat.Shape.STRING) from a DTO field, or changing ObjectMapper to not use BigDecimalAsStringSerializer:

// Removed from DTO:
// @JsonFormat(shape = JsonFormat.Shape.STRING)
private BigDecimal amount;

Before:

{
"transactionId": "TX-456",
"amount": "149.99",
"currency": "EUR"
}

After:

{
"transactionId": "TX-456",
"amount": 149.99,
"currency": "EUR"
}

This looks harmless. But:

  • JavaScript (and many JSON parsers) handle large numbers with floating-point precision loss: 0.1 + 0.2 !== 0.3
  • Financial systems that parse amount as a string and pass it to BigDecimal(String) now receive a JSON number
  • 149.9900000000000002 is a real production bug

Why tests miss it: BigDecimal("149.99").equals(new BigDecimal(149.99)) is false in Java. But the unit test compares Java BigDecimal objects, not the JSON wire format. The test passes. The downstream payment service truncates cents.

5. Custom ObjectMapper Bean Overrides Spring Boot Defaults

The config change:

Defining a custom ObjectMapper @Bean that does not include all modules Spring Boot auto-registers:

@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper()
.registerModule(new JavaTimeModule())
.setSerializationInclusion(JsonInclude.Include.NON_EMPTY);
}

What this breaks: Spring Boot auto-configures ObjectMapper with a specific set of modules, features, and customizers. When you define your own @Bean, Spring Boot's auto-configuration backs off entirely. You lose:

  • Jdk8Module (Optional handling)
  • ParameterNamesModule (constructor deserialization)
  • Any Jackson2ObjectMapperBuilderCustomizer beans from other libraries
  • Default date format settings
  • Default property naming strategy

Before (Spring Boot auto-configured):

{
"accountHolder": "Jane Smith",
"optionalNickname": "JS",
"registeredAt": "2026-01-15T10:00:00Z"
}

After (custom bean without Jdk8Module):

{
"accountHolder": "Jane Smith",
"optionalNickname": {"present": true, "value": "JS"},
"registeredAt": "2026-01-15T10:00:00Z"
}

Optional<String> now serializes as an object with present and value fields instead of the unwrapped value. Every downstream service that reads optionalNickname as a string breaks.

Why tests miss it: If the test runs in a different profile or uses a test-specific ObjectMapper, it does not exercise the production bean.

The Pattern: Zero Logic Change, Total Payload Change

All five cases share the same characteristics:

  1. No business logic changed. The Java objects are identical before and after.
  2. The compiler is happy. No type errors, no warnings.
  3. Unit tests pass. They assert on Java objects, not serialized JSON.
  4. Contract tests pass. They check field presence and types, not exact serialization format.
  5. The serialized HTTP payload is different. The actual bytes sent over the wire changed.

This is why testing at the serialization boundary is critical in microservices. The gap between "the Java object is correct" and "the JSON over HTTP is correct" is where these regressions live.

How to Catch Serialization Regressions

Option 1: Serialization-specific unit tests

Write tests that serialize to JSON and assert on the output:

@Test
void customer_serialization_format_is_stable() {
Customer customer = new Customer("42", "GOLD", null);
String json = objectMapper.writeValueAsString(customer);

assertThatJson(json)
.node("loyaltyTier").isEqualTo("GOLD")
.node("referralCode").isPresent().isNull();
}

This works but requires manual maintenance for every DTO. In a system with hundreds of DTOs, it does not scale.

Option 2: Golden file tests

Serialize objects and compare against committed .json files. Any change to serialization requires an explicit update to the golden file. This is more scalable but still requires you to know which DTOs to test.

Option 3: Before/after trace comparison

BitDive captures the actual serialized HTTP exchanges from your running application. Trigger the same API call before and after the configuration change. BitDive compares the real outgoing payloads:

Diff in POST /api/payments (request body):
- "amount": "149.99" → "amount": 149.99
- "createdAt": "2026-..." → "createdAt": 1772316600000
+ "referralCode" field removed (was null)

This catches all five regressions described above, because it operates on the actual wire format, not on Java objects. The comparison works on traces captured from real API calls, so it reflects the exact bytes your service sends over HTTP. See a real before/after trace comparison in the Interactive Demo with Cursor (video).

Detect Serialization Regressions from Real Traces

BitDive captures real HTTP exchanges and compares them before and after code changes. Jackson config changes, ObjectMapper updates, DTO refactors: caught automatically in your existing JUnit tests.

Try BitDive Free

FAQ

Does Spring Boot lock down Jackson defaults?

Spring Boot auto-configures ObjectMapper with sensible defaults, but these defaults can vary between minor versions. Upgrading from Spring Boot 3.1 to 3.2 can change date serialization behavior if different jackson-datatype modules are resolved. Always test serialization output after a framework upgrade.

Can I prevent these issues with @JsonFormat annotations?

Partially. @JsonFormat on individual fields gives explicit control, but it requires annotating every field. A global ObjectMapper change still affects all unannotated fields. The safest approach is combining explicit annotations on critical fields with automated verification of the actual serialized output.

Do these issues affect Kafka messages too?

Yes. If your Kafka producer uses Jackson for serialization (which is the default with JsonSerializer), all five issues apply to every message sent to Kafka topics. A downstream consumer that expects "ACTIVE" but receives 1 will fail to deserialize the message.


Try BitDive Free