5 Jackson Configuration Changes That Silently Break Your Microservices

Published: (March 4, 2026 at 06:00 PM EST)
8 min read
Source: Dev.to

Source: Dev.to

Source: Dev.to – 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 can change what every outgoing HTTP request looks like. The downstream service can no longer parse the payload, and you’ll discover the issue only in production. Below are five Jackson configuration changes that cause this, each with exact before/after JSON examples.

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.

That 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. Because the change happens at the serialization layer, it is invisible to:

  • Unit tests that mock the HTTP client (they 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’s a serialization change.


1. WRITE_DATES_AS_TIMESTAMPS Turns ISO Strings Into Numbers

The single most common Jackson‑related production incident in Spring Boot applications.

Config change

objectMapper.configure(
    SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true
);

or 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; receiving a Unix timestamp causes a DateTimeParseException.

Why tests miss it – Unit tests that mock the HTTP client never see the serialized JSON. They work with Instant/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 minor Spring Boot version bump can flip this behavior.


2. Include.NON_NULL Makes Fields Disappear

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"
}

When referralCode is null, the downstream service 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. Tests that assert on the Java object see the same result; only the JSON differs.

The trap – Often introduced as a “clean‑up” or “reduce payload size” optimisation. The PR looks harmless: one line, no logic change, no test failures.


3. Enum Serialization Strategy Changes Strings to Integers

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()). Receiving "1" instead of "PROCESSING" 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

Config change

Remove @JsonFormat(shape = JsonFormat.Shape.STRING) from a DTO field, or stop using a custom 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"
}
  • Consequences
    • 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

Config change

objectMapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);

or switch 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 ObjectMapper configuration 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-jsr310 or 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

  • Problem
    Financial systems that parse amount as a string and pass it to BigDecimal(String) now receive a JSON number instead.
    Example bug in production: 149.9900000000000002.

  • Why tests miss it

    BigDecimal bdFromString = new BigDecimal("149.99");
    BigDecimal bdFromDouble = new BigDecimal(149.99);
    System.out.println(bdFromString.equals(bdFromDouble)); // false

    Unit tests compare Java BigDecimal objects directly, not the JSON wire format. Consequently, the test passes while the downstream payment service truncates the cents, leading to a real‑world error.

5. Custom ObjectMapper Bean Overrides Spring Boot Defaults

Configuration change

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

What breaks

Spring Boot auto‑configures an ObjectMapper with a specific set of modules, features, and customizers.
When you define your own @Bean, Spring Boot’s auto‑configuration backs off completely, causing you to lose:

  • Jdk8Module – handles Optional values
  • ParameterNamesModule – enables constructor deserialization
  • Any Jackson2ObjectMapperBuilderCustomizer beans contributed by other libraries
  • Default date‑format settings
  • Default property‑naming strategy

Example

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, breaking downstream services that expect a plain string.

Why tests miss it
If the test runs under a different profile or uses a test‑specific ObjectMapper, it does not exercise the production bean, so the regression goes unnoticed.

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 micro‑services. 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

  1. Serialize objects.
  2. Compare the result against committed .json files (the golden files).
  3. Any change to serialization forces an explicit update of the golden file.

Benefits: Scales better than per‑DTO unit tests.
Drawback: You still need to decide which DTOs to include in the suite.


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 a configuration change; bitDive then diffs 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:

  • Operates on the wire format, not on Java objects, so it catches every regression that affects the actual bytes sent over HTTP.
  • Uses traces captured from real API calls, guaranteeing that the comparison reflects what clients actually receive.

If you don’t want these breakages to surface in production, give bitDive a try. See a concrete example in the Inter‑Service API Verification Guide.

0 views
Back to Blog

Related posts

Read more »