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
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
idrequired and normalizes missing optional data, so no code downstream handlesnullforheadlineorskills. Optional<String>marksheadlineas optional in the type itself. (Records withOptionalneed Jackson'sjackson-datatype-jdk8module registered.)NON_ABSENTleaves an absent optional out of the JSON instead of writing an explicitnull.ignoreUnknown = truelets 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_VALUEwith 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
nullwritten 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.