← Java articles

Java notes

Java Records and Jackson: Null Safety, Schema Evolution, and Round-Trip Tests

Designing JSON contracts that survive years of change, with property-based tests

Shubham Anandani··~9 min read

A record as a contract

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;

import java.util.List;
import java.util.Objects;
import java.util.Optional;

@JsonInclude(JsonInclude.Include.NON_ABSENT)
@JsonIgnoreProperties(ignoreUnknown = true)
public record Profile(String id, Optional<String> headline, List<String> skills) {

    public Profile {
        Objects.requireNonNull(id, "id");
        headline = headline == null ? Optional.empty() : headline;
        skills = skills == null ? List.of() : List.copyOf(skills);
    }
}
  • The compact constructor makes id required and normalizes missing optional data, so no code downstream handles null for headline or skills.
  • Optional<String> marks headline as optional in the type itself. (Records with Optional need Jackson's jackson-datatype-jdk8 module registered.)
  • NON_ABSENT leaves an absent optional out of the JSON instead of writing an explicit null.
  • ignoreUnknown = true lets this reader tolerate fields that newer producers add.
ObjectMapper mapper = new ObjectMapper()
        .registerModule(new Jdk8Module());

Why not plain POJOs?

The silent coercion trap

Profile p = mapper.readValue("{\"id\": 42}", Profile.class);
// p.id() is "42", with no error
import com.fasterxml.jackson.databind.cfg.CoercionAction;
import com.fasterxml.jackson.databind.cfg.CoercionInputShape;
import com.fasterxml.jackson.databind.type.LogicalType;

ObjectMapper strict = new ObjectMapper().registerModule(new Jdk8Module());
strict.coercionConfigFor(LogicalType.Textual)
      .setCoercion(CoercionInputShape.Integer, CoercionAction.Fail)
      .setCoercion(CoercionInputShape.Float, CoercionAction.Fail);

Null, absent, and present

Evolving schemas safely

  • Add new fields as optional, with a defined default.
  • Never change a field's type in place. Add a new field and deprecate the old one.
  • Never reuse a removed field's name for different data.
  • Treat enum changes with care. Configure an unknown-value fallback (for example READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE with a default) so old clients survive new symbols.

Property-based round-trip tests

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import net.jqwik.api.ForAll;
import net.jqwik.api.Property;

import java.util.List;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertEquals;

class ProfileRoundTripTest {

    private final ObjectMapper mapper = new ObjectMapper().registerModule(new Jdk8Module());

    @Property
    void roundTripPreservesData(@ForAll String id,
                                @ForAll Optional<String> headline,
                                @ForAll List<String> skills) throws Exception {
        Profile original = new Profile(id, headline, skills);

        String json = mapper.writeValueAsString(original);
        Profile parsed = mapper.readValue(json, Profile.class);

        assertEquals(original, parsed);
    }
}

Failure modes

  • Silent type coercion hiding a producer's breaking change.
  • Explicit null written by one serializer and omitted by another.
  • Enums that fail on unknown values in old clients.
  • A required field added without a default, breaking old documents.

Demo to build

Takeaways

  • Put invariants and null normalization in the record's compact constructor.
  • Turn off silent coercion where the contract is strict.
  • Evolve schemas additively, and keep golden files from every release.
  • Property-based round-trip tests guard the serialization invariants.