There are two scenarios where Jackson’s built-in annotations are not enough: when you need to control exactly how a complex type is serialised, and when the class you need to annotate belongs to a third-party library whose source you cannot modify. For the first case, Jackson provides custom serialisers and deserialisers. For the second, it provides Mix-in Annotations — a mechanism that lets you attach annotations to any class without touching its source.
Writing a Custom Serialiser
Extend StdSerializer<T> and override serialize(). The method receives the value to write and a JsonGenerator you use to emit JSON tokens.
Suppose you have a Money type that should serialise as a structured JSON object containing the amount and the currency code:
public class Money {
private final BigDecimal amount;
private final String currencyCode;
// Constructor and getters
}
public class MoneySerializer extends StdSerializer<Money> {
public MoneySerializer() {
super(Money.class);
}
@Override
public void serialize(
Money moneyValue,
JsonGenerator jsonGenerator,
SerializerProvider serializerProvider) throws IOException {
jsonGenerator.writeStartObject();
// Write the amount rounded to 2 decimal places
jsonGenerator.writeNumberField("amount",
moneyValue.getAmount().setScale(2, RoundingMode.HALF_UP));
// Write the ISO currency code in uppercase
jsonGenerator.writeStringField("currency",
moneyValue.getCurrencyCode().toUpperCase());
jsonGenerator.writeEndObject();
}
}
Writing a Custom Deserialiser
Extend StdDeserializer<T> and override deserialize(). Use the JsonParser to read the incoming tokens and construct your object:
public class MoneyDeserializer extends StdDeserializer<Money> {
public MoneyDeserializer() {
super(Money.class);
}
@Override
public Money deserialize(
JsonParser jsonParser,
DeserializationContext deserializationContext) throws IOException {
JsonNode rootNode = jsonParser.getCodec().readTree(jsonParser);
BigDecimal amount = rootNode.get("amount").decimalValue();
String currency = rootNode.get("currency").asText();
return new Money(amount, currency);
}
}
Registering the Serialiser and Deserialiser via SimpleModule
SimpleModule is Jackson’s standard mechanism for bundling custom handlers. You create one module per concern, add your serialisers and deserialisers to it, then register it on the mapper once at startup.
Bundle your custom serialisers and deserialisers into a SimpleModule, then register the module on your mapper:
SimpleModule moneyModule = new SimpleModule("MoneyModule", new Version(1, 0, 0, null, null, null));
moneyModule.addSerializer(Money.class, new MoneySerializer());
moneyModule.addDeserializer(Money.class, new MoneyDeserializer());
ObjectMapper mapper = new ObjectMapper().registerModule(moneyModule);
// Serialise
Money price = new Money(new BigDecimal("19.999"), "usd");
System.out.println(mapper.writeValueAsString(price));
// Output: {"amount":20.00,"currency":"USD"}
// Deserialise
Money restored = mapper.readValue("{"amount":20.00,"currency":"USD"}", Money.class);
System.out.println(restored.getAmount()); // Output: 20.00
Mix-in Annotations — Annotating Third-Party Classes
Imagine you are using a third-party Address class from a library you cannot modify:
// 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
}
Create a Mix-in interface that carries the annotations Jackson should apply to Address:
// Mix-in: applied to Address at runtime without touching Address.java
public abstract class AddressMixin {
@JsonIgnore
public String internalTrackingCode; // Suppress this field in JSON
@JsonProperty("zip")
public String postalCode; // Rename postalCode to zip in JSON
}
Register the Mix-in on your mapper:
ObjectMapper mapper = new ObjectMapper();
mapper.addMixIn(Address.class, AddressMixin.class);
Address address = new Address();
address.street = "123 Main St";
address.city = "Springfield";
address.postalCode = "12345";
address.internalTrackingCode = "INTERNAL-X99"; // Should not appear in JSON
System.out.println(mapper.writeValueAsString(address));
// Output: {"street":"123 Main St","city":"Springfield","zip":"12345"}
internalTrackingCode is absent and postalCode appears as zip — all without touching the original class.
How the Code Works
- StdSerializer / StdDeserializer receive the raw
JsonGenerator/JsonParser, giving you full control over token emission and consumption. - SimpleModule is a named bundle. Registering it on the mapper tells Jackson: “for this specific Java type, use these custom handlers instead of the defaults.”
- Mix-ins work by Jackson merging the annotation metadata from the Mix-in class/interface onto the target class before building its serialisation model. The target class itself is never modified at the bytecode level.
See Also
- Part 1: Jackson ObjectMapper Complete Guide
- Part 3: Jackson Annotations Cheat Sheet
- Part 5: Polymorphic Deserialisation
- Part 6: Streaming API and JsonNode
AI Prompts for Custom Serialisers and Mix-ins
Write a Custom Serialiser
I have this domain value object: [paste class here]. Write a Jackson 3 custom serialiser for it by extending StdSerializer. The serialised JSON should look like this: [describe or paste target JSON]. Implement the serialize() method using writeStartObject, writeStringField, writeNumberField, and writeEndObject calls. Register the serialiser in a SimpleModule and show how to add the module to a JsonMapper builder instance.
What it does: Generates a complete StdSerializer subclass with an annotated serialize() implementation matched to your target JSON shape, plus a SimpleModule registration and the JsonMapper.builder().addModule(...).build() wiring — all in one coherent code block.
When to use it: When a domain type — such as a Money, EmailAddress, or UserId value object — has a JSON representation that cannot be expressed through annotations alone and requires custom field selection, computed values, or a non-standard structure.
Write a Custom Deserialiser
I have this incoming JSON structure: [paste JSON here]. Write a Jackson 3 custom deserialiser that reads it into this domain class: [paste class here]. Extend StdDeserializer and implement deserialize() using JsonNode tree traversal. Handle missing fields gracefully with a default value rather than throwing an exception. Register the deserialiser in a SimpleModule and show a complete round-trip test.
What it does: Creates a StdDeserializer subclass that maps the provided JSON structure to your domain class using safe JsonNode.path() traversal (null-safe), with explicit default values for optional or missing fields and a working round-trip test using JsonMapper.
When to use it: When the incoming JSON shape does not map directly to your class — for example when a nested object must be flattened, when field names are inconsistent across API versions, or when certain fields must be validated or transformed during deserialisation rather than after.
Bundle Handlers into a Reusable Module
I have these custom serialisers and deserialisers: [list your classes here]. Bundle them into a named Jackson 3 Module subclass with a setupModule() implementation that registers all of them. The module should be self-contained so any project can register it with a single addModule() call. Show how to expose it as a Spring @Bean and how to write a unit test that verifies both serialisers and deserialisers are active after registration.
What it does: Wraps all provided handlers in a named Module subclass with a setupModule() method, adds a @Bean configuration snippet for Spring, and generates a JUnit 5 test that creates a fresh JsonMapper, registers only the module, and asserts round-trip correctness for each registered type.
When to use it: When building a shared library or internal framework that needs to carry its own JSON handling — for example a common-domain module used by multiple microservices — and you want consumers to get correct serialisation with a single addModule() call rather than having to register individual serialisers.
Conclusion
Custom serialisers and deserialisers give you complete control over JSON representation for any type, while Mix-in Annotations let you apply that control even to classes you do not own. These two tools together cover every advanced Jackson customisation scenario, from legacy domain models to strict API contracts on third-party libraries.