Custom Serialisers and Deserialisers
Write Jackson 3 ValueSerializer and ValueDeserializer classes for types annotations cannot handle, and register them with @JacksonComponent or a module.
@JacksonComponent
class MoneyJson {
static class Serializer extends ObjectValueSerializer<Money> { ... }
static class Deserializer extends ObjectValueDeserializer<Money> { ... }
}Annotations cover most needs. Reach for a custom serialiser only when the JSON shape cannot be described any other way: a domain type that should appear as a single string, a third-party class you cannot annotate, or a legacy format that does not map onto properties.
Try annotations first
A value type with one field, such as Sku or EmailAddress, needs no serialiser at all. Put @JsonValue on the component and Jackson writes "A-1" instead of {"value":"A-1"}, and reads it back the same way through the record's constructor. Validation in that constructor then runs for every request that contains the type.
Writing a serialiser
In Jackson 3 a serialiser extends ValueSerializer<T>, or StdSerializer<T> which also records the handled type. You write tokens to a JsonGenerator with methods such as writeString, writeStartObject and writeStringProperty. A deserialiser extends StdDeserializer<T> and reads from a JsonParser. Neither needs a throws IOException clause any more; Jackson 3 exceptions are unchecked.
Spring Boot adds two helpers. ObjectValueSerializer writes the surrounding braces for you, so you only write properties. ObjectValueDeserializer hands you the object as a JsonNode tree with getRequiredNode and nullSafeValue helpers.
Registering
@JacksonComponenton the serialiser, or on an outer class holding nested serialiser and deserialiser classes. It is a Spring component, so component scanning finds it and Boot registers it with the auto-configured mapper.- A
SimpleModulebean, which suits serialisers for third-party types gathered in one configuration class. @JsonSerialize(using = ...)on one property, when only that field needs special treatment.
Reporting bad input
When a deserialiser meets a value it cannot accept, report it through the DeserializationContext, for example throw ctx.weirdStringException(text, Type.class, "expected three letters"). That produces an InvalidFormatException carrying the property path, which Spring MVC turns into a 400. Throwing your own runtime exception loses that context.
Keep serialisers free of I/O and business logic. They run for every object of that type, including the ones you log, so a database lookup inside one can turn a list endpoint into hundreds of queries.
Example
import org.springframework.boot.jackson.JacksonComponent;
import tools.jackson.core.JsonGenerator;
import tools.jackson.core.JsonParser;
import tools.jackson.databind.DeserializationContext;
import tools.jackson.databind.SerializationContext;
import tools.jackson.databind.deser.std.StdDeserializer;
import tools.jackson.databind.ser.std.StdSerializer;
record CurrencyCode(String value) {}
// Found by component scanning and registered with Boot's JsonMapper.
public class CurrencyCodeJson {
static class Serializer extends StdSerializer<CurrencyCode> {
Serializer() {
super(CurrencyCode.class);
}
public void serialize(CurrencyCode code, JsonGenerator gen, SerializationContext context) {
gen.writeString(code.value());
}
}
static class Deserializer extends StdDeserializer<CurrencyCode> {
Deserializer() {
super(CurrencyCode.class);
}
public CurrencyCode deserialize(JsonParser parser, DeserializationContext context) {
String text = parser.getValueAsString();
if (text == null || !text.matches("[A-Za-z]{3}")) {
// InvalidFormatException with the property path: Spring MVC answers 400.
throw context.weirdStringException(text, CurrencyCode.class, "expected a three-letter ISO 4217 code");
}
return new CurrencyCode(text.toUpperCase());
}
}
}When to use it
- Write monetary amounts as strings so browser clients never round 0.10 + 0.20 into a wrong total.
- Accept currency codes in any case and reject anything that is not three letters with a 400 that names the field.
- Serialise a third-party library type you cannot annotate in the shape your API documents.
More examples
A value type with no serialiser at all
<code>@JsonValue</code> works in both directions for a single-component record. Use this for identifiers and codes before writing any serialiser; it is less code and the rule lives in the type.
import com.fasterxml.jackson.annotation.JsonValue;
public record Sku(@JsonValue String value) {
public Sku {
if (value == null || !value.matches("[A-Z]-\\d+")) {
throw new IllegalArgumentException("Invalid SKU: " + value);
}
}
}
// record OrderLine(Sku sku, int quantity) {}
// is written and read as {"sku":"A-1","quantity":2}, not {"sku":{"value":"A-1"},...}An object-shaped type with Spring Boot's helpers
<code>ObjectValueSerializer</code> writes the opening and closing braces, so you only write the properties. Writing the amount as a string avoids floating-point rounding in browsers, where JSON numbers become doubles; the result is <code>{"amount":"12.50","currency":"GBP"}</code>. The deserialiser must then read the amount as text: <code>nullSafeValue(node, BigDecimal.class)</code> calls <code>decimalValue()</code>, which fails on a string node, so the type would not survive its own round trip.
import java.math.BigDecimal;
import org.springframework.boot.jackson.JacksonComponent;
import org.springframework.boot.jackson.ObjectValueDeserializer;
import org.springframework.boot.jackson.ObjectValueSerializer;
import tools.jackson.core.JsonGenerator;
import tools.jackson.core.JsonParser;
import tools.jackson.databind.DeserializationContext;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.SerializationContext;
record Money(BigDecimal amount, String currency) {}
@JacksonComponent
public class MoneyJson {
static class Serializer extends ObjectValueSerializer<Money> {
@Override
protected void serializeObject(Money money, JsonGenerator gen, SerializationContext context) {
// Amount as a string so JavaScript clients do not lose precision.
gen.writeStringProperty("amount", money.amount().toPlainString());
gen.writeStringProperty("currency", money.currency());
}
}
static class Deserializer extends ObjectValueDeserializer<Money> {
@Override
protected Money deserializeObject(JsonParser parser, DeserializationContext context, JsonNode tree) {
// The serialiser writes the amount as a string, so read it as text:
// nullSafeValue(node, BigDecimal.class) only accepts a JSON number.
BigDecimal amount = new BigDecimal(getRequiredNode(tree, "amount").asString());
String currency = nullSafeValue(getRequiredNode(tree, "currency"), String.class);
return new Money(amount, currency);
}
}
}Custom output for one property only
The serialiser applies only to <code>cardNumber</code>; every other <code>String</code> in the application is untouched. <code>@JsonSerialize</code> lives in <code>tools.jackson.databind.annotation</code> in Jackson 3. Better still, never put the full number in the response record; masking is a second line of defence.
import tools.jackson.core.JsonGenerator;
import tools.jackson.databind.SerializationContext;
import tools.jackson.databind.annotation.JsonSerialize;
import tools.jackson.databind.ser.std.StdSerializer;
class MaskedCardSerializer extends StdSerializer<String> {
MaskedCardSerializer() {
super(String.class);
}
@Override
public void serialize(String number, JsonGenerator gen, SerializationContext context) {
String last4 = number.length() <= 4 ? number : number.substring(number.length() - 4);
gen.writeString("**** " + last4);
}
}
public record PaymentMethodResponse(
String id,
String brand,
@JsonSerialize(using = MaskedCardSerializer.class) String cardNumber) {
}
Discussion