Skip to main content

Custom Serialisers, Deserialisers and Mix-ins in Jackson 3

Jackson’s annotations run out in two places: when a type’s JSON shape has to differ from its Java shape, and when you don’t own the class. This post rebuilds custom serialisers, deserialisers and mix-ins from scratch against a real Jackson 3.2.3 build — a Money value object, a third-party Address class, and two checks nobody else runs: whether the JsonSerializer-to-ValueSerializer rename is just cosmetic, and whether a mix-in still works on a record.

Jackson’s annotations cover almost every JSON shape you will ever need — rename a field, ignore one, format a date, allow an alias. They stop covering you in exactly two situations: when a type’s JSON representation has to be genuinely different from its Java shape (a Money value object that should serialise as a rounded, uppercased currency pair rather than a raw BigDecimal and a String), and when the class you need to annotate is not yours to annotate — it ships in a library, and you cannot add @JsonProperty to its source. For the first problem Jackson gives you custom serialisers and deserialisers. For the second it gives you Mix-in Annotations, a way to attach annotations to a class without touching it.

This post rebuilds both from scratch against a real Jackson 3.2.3 build, and along the way checks two things nobody writes about: whether the Jackson 2 → 3 renames in this corner of the API (JsonSerializer becoming ValueSerializer, and friends) are just a find-and-replace, and whether a mix-in — designed in an era of fields and getters — still works on a record.

Versions used in this post. Jackson 3.2.3 (tools.jackson.core:jackson-databind, verified against Maven Central’s maven-metadata.xml), jackson-annotations 2.22 (deliberately still on the old com.fasterxml.jackson.core group ID), JDK 25 (Temurin 25.0.4.1+1), Maven 3.9.9. If you arrived here from a Spring Boot 4.1.1 project: its BOM currently pins jackson-databind to 3.1.5, one minor behind standalone Jackson — nothing in this post changed between those two versions, but check what else Boot 4.1 changed about Jackson before assuming your version matches this one.

Runnable code: every example below is real, committed, and actually executed — in the jackson3-by-example repository, package part4custom, output captured under docs/output/ by run-all.sh. Nothing below is typed from memory of how Jackson 2 used to behave.

Two Paths From One Mapper

Every type Jackson serialises goes through the same mapper, but not through the same code. Most types take the default path: Jackson looks at your class’s fields, getters, or record components, builds a model of its properties, and writes each one out as a JSON property with the same name. A Money record with amount and currencyCode components serialises, by default, as exactly that — {"amount":19.999,"currencyCode":"usd"}. That is Jackson introspecting your class.

The other path is the one this post is about: you register a handler for a specific type, and Jackson routes every instance of that type through your code instead of its own introspection. Nothing about the class changes. The decision of which path a value takes happens once, when the mapper is built, based purely on “is there a registered handler for this exact type?”

One Money value, two possible paths through JsonMapper Money(19.999, “usd”) no serialiser registered E01MoneyValueSerializer registered {“amount”:19.999, “currencyCode”:”usd”} {“amount”:20.00,”currency”:”USD”} Same object, same mapper. The only difference is whether a handler for Money’s exact type was registered at build time. The default path exposes your Java shape verbatim; the custom path lets you round, rename and reformat on the way out.

Both paths are shown for real, side by side, further down — the “no module” lines in this post’s output blocks are the top lane; everything with a module registered is the bottom one.

The Smallest Working Example: a Custom Serialiser for Money

Suppose you have this value object, and you want it to serialise as a structured object with the amount rounded to two decimal places and the currency code uppercased — neither of which is how a plain record would write itself:

public record Money(BigDecimal amount, String currencyCode) { }

Source: Money.java.

Extend StdSerializer<T> and override serialize(). You get the value and a JsonGenerator you use to emit JSON tokens one at a time — writeStartObject, a property, another property, writeEndObject:

// Jackson 3: tools.jackson.databind.ser.std.StdSerializer
// StdSerializer kept its name; it now extends ValueSerializer (was JsonSerializer).
public class E01MoneyValueSerializer extends StdSerializer<Money> {

    public E01MoneyValueSerializer() {
        super(Money.class);
    }

    @Override
    public void serialize(Money moneyValue, JsonGenerator jsonGenerator,
                           SerializationContext ctxt) {   // was SerializerProvider
                                                            // no `throws IOException` - unchecked now
        jsonGenerator.writeStartObject();
        jsonGenerator.writeNumberProperty("amount",        // was writeNumberField
                moneyValue.amount().setScale(2, RoundingMode.HALF_UP));
        jsonGenerator.writeStringProperty("currency",      // was writeStringField
                moneyValue.currencyCode().toUpperCase());
        jsonGenerator.writeEndObject();
    }
}

Source: E01MoneyValueSerializer.java. This class has no main() of its own — it is exercised together with the deserialiser a few sections down, and the real output from running both appears there.

Three renames show up the moment you port a Jackson 2 custom serialiser: the package is tools.jackson.databind.ser.std, the context parameter is SerializationContext rather than SerializerProvider, and there is no throws IOException on the method — Jackson 3 made its exceptions unchecked throughout, so a serialiser that compiled against Jackson 2 with a throws clause simply stops compiling.

Writing a Custom Deserialiser — Where the Real Traps Are

The deserialising half is the one worth slowing down for, because two of its differences from Jackson 2 fail silently in the direction you would least expect: not with a compile error, but with a wrong answer or a worse exception message at runtime.

public class E02MoneyValueDeserializer extends StdDeserializer<Money> {

    public E02MoneyValueDeserializer() {
        super(Money.class);
    }

    @Override
    public Money deserialize(JsonParser jsonParser, DeserializationContext ctxt) {
        JsonNode rootNode = ctxt.readTree(jsonParser);     // was jsonParser.getCodec().readTree(jsonParser)

        BigDecimal amount = rootNode.path("amount").decimalValue(BigDecimal.ZERO);
        String currency   = rootNode.path("currency").asString("GBP");
        return new Money(amount, currency);
    }
}

Source: E02MoneyValueDeserializer.java.

JsonParser.getCodec() is gone in Jackson 3 — reading a tree from inside a deserialiser now goes through the DeserializationContext: ctxt.readTree(parser). That part is a clean rename and the compiler catches it immediately. The other two are not compile errors, which is what makes them worth a callout each.

get() versus path() on a missing field. JsonNode.get("currency") returns a real Java null when the field is absent. Call .decimalValue() or .asString() on that and you get a NullPointerException with a stack trace that says nothing about JSON. path("currency") returns a MissingNode instead of null, so chaining never NPEs — this deserialiser uses path() for exactly that reason.
A bare decimalValue() on a MissingNode throws. This is the one that catches people who already knew to avoid get(). In Jackson 2, MissingNode.decimalValue() quietly returned BigDecimal.ZERO. In Jackson 3 it throws. The fix is the defaulting overload used above — decimalValue(BigDecimal.ZERO) — which is a different method, not the same method with safer behaviour. Code upgraded line-for-line from Jackson 2 that called the no-argument decimalValue() on a path that can be missing will compile fine and throw in production the first time a field is absent.

Registering Both Handlers with a SimpleModule

SimpleModule is how you bundle a serialiser and a deserialiser for one type into something you register once. The registration step is also where the post’s biggest behavioural surprise shows up — not a rename, a real loss of precision.

// Jackson 3: SimpleModule moved to tools.jackson.databind.module, and the
// Version-taking constructor is gone - pass just a name.
SimpleModule moneyModule = new SimpleModule("MoneyModule");
moneyModule.addSerializer(Money.class,   new E01MoneyValueSerializer());
moneyModule.addDeserializer(Money.class, new E02MoneyValueDeserializer());

// The module is attached to the BUILDER. mapper.registerModule() does not exist
// in Jackson 3, because a built mapper is immutable.
JsonMapper mapper = JsonMapper.builder()
        .addModule(moneyModule)
        .build();

Source: E03SimpleModuleRegistration.java. Real output from running it:

serialised   : {"amount":20.00,"currency":"USD"}
amount       : 20.0  (scale lost)
amount exact : 20.00  (scale preserved)
missing field: Money[amount=0, currencyCode=EUR]
no module    : {"amount":19.999,"currencyCode":"usd"}

Output: E03SimpleModuleRegistration.txt. The last line is the “no serialiser registered” lane from the diagram above, run for real against the exact same mapper setup minus the module.

Reading back 20.00 gives you 20.0, and for money that is a bug. Jackson parses a JSON number literal like 20.00 as a double first and converts afterward, so the trailing zero — the scale — is gone before your deserialiser ever sees it. If scale matters, enable DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS on the mapper, shown as amount exact above. This is a Jackson default, not a bug in the deserialiser above; it survived the Jackson 2 → 3 transition completely unchanged, which is exactly why it is easy to assume it isn’t there.

Mix-in Annotations: Annotating a Class You Don’t Own

Everything so far assumed you can edit the class. Mix-ins are for when you cannot — a type from a dependency, generated code, or legacy code nobody wants to touch:

// Third-party class - source is not available
public class Address {
    public String street;
    public String city;
    public String postalCode;
    public String internalTrackingCode; // you never want this in JSON
}

(Defined as a nested class inside E04MixinAnnotations.java — a stand-in for the real third-party class you’d actually be mixing into.)

A mix-in is a second class or interface that carries the annotations Jackson should apply to the real one. Nothing about the mix-in’s own fields matters except their names — Jackson matches them to the target type’s properties by name and merges the annotations in:

// Mix-in: applied to Address at runtime without touching Address.java
public abstract class AddressMixin {
    @JsonIgnore            public String internalTrackingCode;  // suppress
    @JsonProperty("zip")   public String postalCode;            // rename
}

// Jackson 3: mix-ins are registered on the BUILDER. mapper.addMixIn() does not
// exist, because the built mapper is immutable.
JsonMapper mapper = JsonMapper.builder()
        .addMixIn(Address.class, AddressMixin.class)
        .build();

Source: E04MixinAnnotations.java. Real output:

with mixin   : {"city":"Springfield","street":"123 Main St","zip":"12345"}
without mixin: {"city":"Springfield","internalTrackingCode":"INTERNAL-X99","postalCode":"12345","street":"123 Main St"}

Output: E04MixinAnnotations.txt. The second line is the proof that nothing about Address itself changed — a mapper built without the mix-in still sees both fields under their original names, because the merge happens inside the mapper’s annotation model, never at the bytecode level.

How a mix-in reaches a class it never touches Address.class (unannotated) street city postalCode internalTrackingCode AddressMixin @JsonIgnore internalTrackingCode @JsonProperty(“zip”) postalCode effective annotation model street, city -> unchanged postalCode -> serialised as “zip” internalTrackingCode -> ignored The merge happens once, by matching property name – Address.java is never recompiled or subclassed.
Going deeper: when a mix-in’s own members carry no annotations

A mix-in method or field with no Jackson annotation on it at all contributes nothing — it is not a way to “declare” a property that should exist with default behaviour, and an extra field in the mix-in that doesn’t exist on the target is simply never matched to anything. The only thing Jackson reads off a mix-in member is its annotations, keyed by matching its name against the target type’s introspected properties. If you rename a field in the mix-in without renaming it in the real class, the mix-in annotation is silently never applied — there is no error, because from Jackson’s point of view that property on the mix-in just doesn’t correspond to anything on the target. Treat a name mismatch in a mix-in as a bug with no warning, not an unsupported combination.

The Renames Nobody Warns You About

Both the serialiser and deserialiser types used above kept a familiar name — StdSerializer and StdDeserializer still exist in Jackson 3 and still subclass the things you extend. That stability is exactly what hides the real rename, one level up:

Jackson 2Jackson 3
JsonSerializer<T>ValueSerializer<T>
JsonDeserializer<T>ValueDeserializer<T>
SerializerProviderSerializationContext
writeNumberField / writeStringFieldwriteNumberProperty / writeStringProperty
parser.getCodec().readTree(parser)ctxt.readTree(parser)
mapper.registerModule(m)builder.addModule(m)
mapper.addMixIn(a, b)builder.addMixIn(a, b)
new SimpleModule(name, Version)new SimpleModule(name)
every throws IOExceptionremoved — unchecked throughout

StdSerializer and StdDeserializer did not get renamed; they got re-pointed — they now extend ValueSerializer/ValueDeserializer instead of the old JsonSerializer/JsonDeserializer. Extending ValueSerializer directly, skipping the Std* middle layer entirely, makes the rename visible instead of hidden behind a name that happens to still compile:

public record UserId(String value) { }

static class UserIdSerializer extends ValueSerializer<UserId> {
    @Override
    public void serialize(UserId id, JsonGenerator gen, SerializationContext ctxt) {
        gen.writeString(id.value());
    }
}

static class UserIdDeserializer extends ValueDeserializer<UserId> {
    @Override
    public UserId deserialize(JsonParser p, DeserializationContext ctxt) {
        return new UserId(p.getString());
    }
}

Source: E05ValueSerializerDirect.java and E06ValueDeserializerDirect.java. Together they round-trip a one-component record as a bare JSON string — "u-42" — instead of the structural {"value":"u-42"} a record would otherwise produce. Real output, both directions:

serialised : {"assignee":"u-42","title":"Fix build"}
round-trip : Ticket[assignee=UserId[value=u-42], title=Fix build]
equal      : true
no module  : MismatchedInputException - Cannot construct instance of `com.ankurm.jackson3.part4custom.E06ValueDeserializerDirect$UserId` (although at least one Creator exists): no String-argument constructor/factory method to deserialize from String value ('u-42')

Output: E06ValueDeserializerDirect.txt. That last line is what happens if you serialise with the module but forget to register it before deserialising — a genuinely useful error for diagnosing a half-wired module, since it names the exact type and the exact value it choked on.

Do Mix-ins Work on a Record?

Every mix-in example in print, including the one earlier in this post, targets a field-and-getter class. Records are everywhere in modern Java, and a record’s “properties” are not fields at all — they are the canonical accessor methods, named exactly like the record component (postalCode(), not getPostalCode()). Whether a mix-in still applies was worth actually running rather than assuming either way.

The merge matches by property name, not by declaration kind POJO: Address (fields) field postalCode mixin field postalCode -> matches record: AddressRecord (accessors) method postalCode() mixin method postalCode() -> matches same property name -> same merge A mix-in member that says getPostalCode() instead of postalCode() matches nothing on a record – silently.
public record AddressRecord(String street, String city, String postalCode,
                             String internalTrackingCode) { }

// Method signatures, not fields - to match a record's own accessor names.
public interface AddressRecordMixin {
    @JsonIgnore
    String internalTrackingCode();

    @JsonProperty("zip")
    String postalCode();
}

JsonMapper withMixin = JsonMapper.builder()
        .addMixIn(AddressRecord.class, AddressRecordMixin.class)
        .build();

Source: E07MixinOnRecord.java. Real output:

with mixin   : {"street":"123 Main St","city":"Springfield","zip":"12345"}
without mixin: {"street":"123 Main St","city":"Springfield","postalCode":"12345","internalTrackingCode":"INTERNAL-X99"}

Output: E07MixinOnRecord.txt. It works, and it works for the reason you would expect once you see it work: Jackson’s mix-in merge keys off the introspected property name, not the declaration kind that produced it. A record’s introspected property name is its accessor method’s name, so a mix-in that declares a method with that same name is indistinguishable, to the merge step, from a field with that name on a POJO.

The one way this goes wrong. Write the mix-in method as getPostalCode() out of getter habit and it will not throw — it will just silently fail to match anything, because AddressRecord has no property named postalCode via a getPostalCode accessor; its property is named postalCode via a postalCode() accessor. The mix-in loads, compiles, and does nothing, with no error at any point. Name mix-in members after the target’s real introspected property name, not after a naming convention you’re used to.

Should You Even Write a Custom Serialiser?

Usually, no — reach for annotations first. @JsonProperty, @JsonFormat, @JsonInclude and a mix-in interface cover the overwhelming majority of “this field needs to look different in JSON” problems, and they cost you nothing to maintain. Write a real ValueSerializer/ValueDeserializer pair only when the JSON shape is structurally different from the Java shape — a value object collapsing to a single scalar, a computed field with no backing property, or output that needs to vary by more context than an annotation parameter can express. And mix-ins exist for code you don’t own; if you own the class, annotate it directly — a mix-in for your own type is an extra file that can silently drift out of sync with the real one, for no benefit a direct annotation wouldn’t have given you.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.