Custom Serialisers and Deserialisers

Write Jackson 3 ValueSerializer and ValueDeserializer classes for types annotations cannot handle, and register them with @JacksonComponent or a module.

Syntax@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

  • @JacksonComponent on 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 SimpleModule bean, 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

Example · java
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.
@JacksonComponent
public class CurrencyCodeJson {

    static class Serializer extends StdSerializer<CurrencyCode> {
        Serializer() {
            super(CurrencyCode.class);
        }

        @Override
        public void serialize(CurrencyCode code, JsonGenerator gen, SerializationContext context) {
            gen.writeString(code.value());
        }
    }

    static class Deserializer extends StdDeserializer<CurrencyCode> {
        Deserializer() {
            super(CurrencyCode.class);
        }

        @Override
        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.

Example · java
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.

Example · java
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.

Example · java
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

  • Be the first to comment on this lesson.