5 Jackson Configuration Changes That Silently Break Your Microservices
Source: Dev.to
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)
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, causing a 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. Contract tests only verify that createdAt exists and is not null, not its 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 the field is absent, it keeps the old value—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 differs.
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 the 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" and throws an 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. Tests asserting assertEquals(OrderStatus.PROCESSING, order.getStatus()) still pass.
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, potentially losing exactness.
- Some downstream services treat the amount as a string to preserve scale and avoid rounding errors.
Why tests miss it: Unit tests compare the BigDecimal value, not its JSON representation. The change is invisible until the payload reaches the consumer.
5. Property Naming Strategy Switches from snake_case to camelCase
The config change
objectMapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
or switching to PropertyNamingStrategies.LOWER_CAMEL_CASE.
Before (snake_case)
{
"user_id": 123,
"first_name": "Alice",
"last_name": "Smith"
}
After (camelCase)
{
"userId": 123,
"firstName": "Alice",
"lastName": "Smith"
}
The downstream service expects snake_case field names. Receiving camelCase causes missing‑field errors or default values.
Why tests miss it: The Java POJO fields remain the same (userId, firstName, lastName). Tests that validate the object state succeed; only the serialized JSON differs.
Takeaways
- Treat
ObjectMapperconfiguration as a contract‑breaking change. - Add integration tests that capture the exact outbound JSON (e.g., using WireMock or a mock server that records the request body).
- Pin Jackson versions and review any changes to
jackson-datatype-jsr310or related modules when upgrading Spring Boot. - Document serialization expectations (date format, enum representation, null handling, numeric precision) in your API spec and enforce them with contract tests.
A single line in the mapper can silently break downstream integrations—guard against it with proper testing and clear documentation.
Point precision loss: 0.1 + 0.2 !== 0.3
-
Financial systems that parse
amountas a string and pass it toBigDecimal(String)now receive a JSON number.149.9900000000000002is 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, while the downstream payment service truncates cents.
5. Custom ObjectMapper Bean Overrides Spring Boot Defaults
The config change
@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, causing you to lose:
Jdk8Module(Optional handling)ParameterNamesModule(constructor deserialization)- Any
Jackson2ObjectMapperBuilderCustomizerbeans 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 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:
- No business logic changed. The Java objects are identical before and after.
- The compiler is happy. No type errors, no warnings.
- Unit tests pass. They assert on Java objects, not serialized JSON.
- Contract tests pass. They check field presence and types, not exact serialization format.
- 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();
}
Pros: Direct verification of the JSON format.
Cons: Requires manual maintenance for every DTO; does not scale well with hundreds of DTOs.
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 scales better but still needs 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)
Why it works: It operates on the actual wire format, not on Java objects, catching all five regressions described above. The comparison uses traces captured from real API calls, reflecting the exact bytes your service sends over HTTP.
If you don’t want to discover these breakages in production, check out bitDive. You can see exactly how before/after trace comparison catches these silent breakages in the Inter‑Service API Verification Guide.