Series MapLesson 14 / 32
Build CoreOrdered learning track

Learn Java Data Mapper Json Xml Validation Part 014 Custom Serializers Deserializers

11 min read2027 words
PrevNext
Lesson 1432 lesson track0718 Build Core

title: Learn Java Data Mapper, JSON/XML Processing & Validation - Part 014 description: Custom Jackson serializers and deserializers: JsonSerializer, JsonDeserializer, StdSerializer, StdDeserializer, module registration, contextual codec, error handling, testing, and production rules. series: learn-java-data-mapper-json-xml-validation seriesTitle: Learn Java Data Mapper, JSON/XML Processing & Validation order: 14 partTitle: Custom Serializers and Deserializers: When Annotation Is Not Enough tags:

  • java
  • jackson
  • custom-serializer
  • custom-deserializer
  • jsonserializer
  • jsondeserializer
  • module
  • serialization
  • deserialization
  • data-mapper date: 2026-06-29

Part 014 — Custom Serializers and Deserializers: When Annotation Is Not Enough

Target skill: mampu menulis custom Jackson serializer/deserializer yang benar, kecil, testable, aman, dan dipakai hanya ketika annotation/DTO/mapper tidak cukup.

Annotation cukup untuk banyak kasus:

@JsonProperty("customer_id")
String customerId;

Tetapi annotation tidak selalu cukup.

Contoh kebutuhan:

  • legacy payload membungkus nilai dalam bentuk aneh
  • satu field bisa datang sebagai string atau object
  • external provider mengirim amount dalam minor unit tetapi domain butuh money object
  • value object ingin diserialisasi sebagai scalar
  • field perlu masked saat output
  • payload punya discriminator non-standard
  • date format legacy membutuhkan parser khusus
  • unknown enum harus preserve raw
  • third-party class tidak bisa diubah
  • JSON shape harus berbeda dari object shape secara signifikan

Di sinilah custom serializers/deserializers masuk.

Namun ini juga area berbahaya. Custom codec mudah berubah menjadi tempat tersembunyi untuk business logic.

Mental model:

A custom serializer/deserializer should translate representation, not decide business behavior.


1. Kaufman Deconstruction

Subskill custom codec:

SubskillKemampuan
Decide necessityTahu kapan perlu custom codec dan kapan cukup DTO/annotation
Write serializerMengubah Java value ke JSON tokens dengan JsonGenerator
Write deserializerMembaca JSON tokens dengan JsonParser
Use standard base classesMemakai StdSerializer/StdDeserializer
Register locally/globallyAnnotation vs module registration
Preserve error contextMelaporkan error yang jelas dan path-aware
Handle nullsMemahami null handling dan default behavior
Compose with ObjectMapperDelegasi ke existing serializer/deserializer jika perlu
Test round-tripFixture valid/invalid/edge
Avoid business leakageCodec hanya representation mapping

Latihan:

  1. Buat CustomerId value object.
  2. Serialize sebagai string.
  3. Deserialize dari string.
  4. Buat invalid input tests.
  5. Register serializer via annotation.
  6. Register serializer via module.
  7. Buat custom money serializer/deserializer.
  8. Test error messages.

2. Decision: Annotation, DTO, Mapper, or Custom Codec?

Decision matrix:

ProblemBest Default
rename field@JsonProperty
omit null@JsonInclude
accept old field name@JsonAlias
value object as string@JsonValue/@JsonCreator or custom codec
third-party class shapemix-in or custom codec
legacy field can be scalar/objectcustom deserializer
output must mask sensitive valueDTO projection or custom serializer
complex event discriminatorcustom deserializer or explicit dispatcher
domain conversion with business rulesmapper/domain service, not codec

3. Serializer Basics

A serializer turns Java object into JSON.

Value object:

public record CustomerId(String value) {
    public CustomerId {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("customer id is required");
        }
    }
}

Serializer:

public final class CustomerIdSerializer extends JsonSerializer<CustomerId> {
    @Override
    public void serialize(
        CustomerId value,
        JsonGenerator gen,
        SerializerProvider serializers
    ) throws IOException {
        gen.writeString(value.value());
    }
}

Usage with annotation:

public record CustomerResponse(
    @JsonSerialize(using = CustomerIdSerializer.class)
    CustomerId customerId,

    String fullName
) {}

Output:

{
  "customerId": "CUS-001",
  "fullName": "Ana"
}

4. StdSerializer

StdSerializer is a common base class for custom serializers.

public final class CustomerIdStdSerializer extends StdSerializer<CustomerId> {

    public CustomerIdStdSerializer() {
        super(CustomerId.class);
    }

    @Override
    public void serialize(
        CustomerId value,
        JsonGenerator gen,
        SerializerProvider provider
    ) throws IOException {
        gen.writeString(value.value());
    }
}

Use StdSerializer when you want standard Jackson base behavior and clearer handled type.


5. Deserializer Basics

A deserializer turns JSON into Java object.

public final class CustomerIdDeserializer extends JsonDeserializer<CustomerId> {
    @Override
    public CustomerId deserialize(
        JsonParser parser,
        DeserializationContext context
    ) throws IOException {
        if (parser.currentToken() != JsonToken.VALUE_STRING) {
            throw context.wrongTokenException(
                parser,
                CustomerId.class,
                JsonToken.VALUE_STRING,
                "customerId must be a string"
            );
        }

        String raw = parser.getValueAsString();

        try {
            return new CustomerId(raw);
        } catch (IllegalArgumentException ex) {
            throw JsonMappingException.from(parser, ex.getMessage(), ex);
        }
    }
}

Usage:

public record CustomerRequest(
    @JsonDeserialize(using = CustomerIdDeserializer.class)
    CustomerId customerId
) {}

Input:

{
  "customerId": "CUS-001"
}

6. StdDeserializer

public final class CustomerIdStdDeserializer extends StdDeserializer<CustomerId> {

    public CustomerIdStdDeserializer() {
        super(CustomerId.class);
    }

    @Override
    public CustomerId deserialize(
        JsonParser parser,
        DeserializationContext context
    ) throws IOException {
        if (parser.currentToken() != JsonToken.VALUE_STRING) {
            return (CustomerId) context.handleUnexpectedToken(
                CustomerId.class,
                parser
            );
        }

        String raw = parser.getText();

        try {
            return new CustomerId(raw);
        } catch (IllegalArgumentException ex) {
            throw JsonMappingException.from(parser, ex.getMessage(), ex);
        }
    }
}

7. Registering Codec with Module

Annotation localizes codec to property/class. Module registration applies type-wide.

SimpleModule module = new SimpleModule("CustomerIdModule")
    .addSerializer(CustomerId.class, new CustomerIdSerializer())
    .addDeserializer(CustomerId.class, new CustomerIdDeserializer());

ObjectMapper mapper = JsonMapper.builder()
    .addModule(module)
    .build();

Decision:

RegistrationWhen
property annotationone field only
class annotationtype owns JSON representation
moduleshared representation across app
mix-inexternal class or alternate profile
ObjectReader/ObjectWriterboundary-specific behavior

Be careful with global registration. It affects every occurrence of that type for that mapper.


8. Custom Money Serializer

Domain type:

public record Money(BigDecimal amount, Currency currency) {
    public Money {
        if (amount == null) {
            throw new IllegalArgumentException("amount is required");
        }
        if (currency == null) {
            throw new IllegalArgumentException("currency is required");
        }
        if (amount.scale() > currency.getDefaultFractionDigits()) {
            throw new IllegalArgumentException("invalid scale for currency");
        }
    }
}

Wire shape:

{
  "amount": "100.00",
  "currency": "IDR"
}

Serializer:

public final class MoneySerializer extends StdSerializer<Money> {

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

    @Override
    public void serialize(
        Money value,
        JsonGenerator gen,
        SerializerProvider provider
    ) throws IOException {
        gen.writeStartObject();
        gen.writeStringField("amount", value.amount().toPlainString());
        gen.writeStringField("currency", value.currency().getCurrencyCode());
        gen.writeEndObject();
    }
}

Why toPlainString()?

  • avoids scientific notation
  • clearer for money contracts
  • preserves decimal representation better than generic formatting

Still test scale behavior explicitly.


9. Custom Money Deserializer

public final class MoneyDeserializer extends StdDeserializer<Money> {

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

    @Override
    public Money deserialize(
        JsonParser parser,
        DeserializationContext context
    ) throws IOException {
        if (parser.currentToken() != JsonToken.START_OBJECT) {
            return (Money) context.handleUnexpectedToken(Money.class, parser);
        }

        BigDecimal amount = null;
        Currency currency = null;

        while (parser.nextToken() != JsonToken.END_OBJECT) {
            String fieldName = parser.currentName();
            parser.nextToken();

            switch (fieldName) {
                case "amount" -> amount = readAmount(parser);
                case "currency" -> currency = readCurrency(parser);
                default -> parser.skipChildren();
            }
        }

        if (amount == null) {
            throw JsonMappingException.from(parser, "amount is required");
        }
        if (currency == null) {
            throw JsonMappingException.from(parser, "currency is required");
        }

        try {
            return new Money(amount, currency);
        } catch (IllegalArgumentException ex) {
            throw JsonMappingException.from(parser, ex.getMessage(), ex);
        }
    }

    private BigDecimal readAmount(JsonParser parser) throws IOException {
        if (parser.currentToken() != JsonToken.VALUE_STRING) {
            throw JsonMappingException.from(parser, "amount must be decimal string");
        }

        try {
            return new BigDecimal(parser.getText());
        } catch (NumberFormatException ex) {
            throw JsonMappingException.from(parser, "amount must be decimal string", ex);
        }
    }

    private Currency readCurrency(JsonParser parser) throws IOException {
        if (parser.currentToken() != JsonToken.VALUE_STRING) {
            throw JsonMappingException.from(parser, "currency must be string");
        }

        try {
            return Currency.getInstance(parser.getText());
        } catch (IllegalArgumentException ex) {
            throw JsonMappingException.from(parser, "currency must be ISO-4217 code", ex);
        }
    }
}

Note the split:

  • deserializer parses representation
  • Money constructor enforces invariant
  • business rounding/fee decision is not inside deserializer

10. Scalar-or-Object Deserializer

Legacy providers sometimes send inconsistent shape.

Input variant A:

{
  "customer": "CUS-001"
}

Input variant B:

{
  "customer": {
    "id": "CUS-001",
    "name": "Ana"
  }
}

Target:

public record CustomerRef(
    String id,
    String displayName
) {}

Deserializer:

public final class CustomerRefDeserializer extends StdDeserializer<CustomerRef> {

    public CustomerRefDeserializer() {
        super(CustomerRef.class);
    }

    @Override
    public CustomerRef deserialize(
        JsonParser parser,
        DeserializationContext context
    ) throws IOException {
        JsonToken token = parser.currentToken();

        if (token == JsonToken.VALUE_STRING) {
            return new CustomerRef(parser.getText(), null);
        }

        if (token == JsonToken.START_OBJECT) {
            String id = null;
            String name = null;

            while (parser.nextToken() != JsonToken.END_OBJECT) {
                String field = parser.currentName();
                parser.nextToken();

                switch (field) {
                    case "id" -> id = parser.getValueAsString();
                    case "name" -> name = parser.getValueAsString();
                    default -> parser.skipChildren();
                }
            }

            if (id == null || id.isBlank()) {
                throw JsonMappingException.from(parser, "customer.id is required");
            }

            return new CustomerRef(id, name);
        }

        return (CustomerRef) context.handleUnexpectedToken(CustomerRef.class, parser);
    }
}

This is a representation compatibility bridge. Add deprecation and telemetry if one shape should disappear later.


11. Delegating to Existing Deserializer

Sometimes custom deserializer needs to read a helper DTO.

public record MoneyJson(
    String amount,
    String currency
) {}
public final class MoneyDelegatingDeserializer extends StdDeserializer<Money> {

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

    @Override
    public Money deserialize(
        JsonParser parser,
        DeserializationContext context
    ) throws IOException {
        ObjectCodec codec = parser.getCodec();
        MoneyJson json = codec.readValue(parser, MoneyJson.class);

        try {
            return new Money(
                new BigDecimal(json.amount()),
                Currency.getInstance(json.currency())
            );
        } catch (RuntimeException ex) {
            throw JsonMappingException.from(parser, "invalid money", ex);
        }
    }
}

This is simpler, but it materializes helper object and delegates shape parsing to databind.

Use this when:

  • payload object is not huge
  • helper DTO is clearer
  • you want annotation/validation-like shape
  • token-level parsing is unnecessary

12. Contextual Serializer

Sometimes serialization depends on property annotation.

Example annotation:

@Target({ ElementType.FIELD, ElementType.RECORD_COMPONENT })
@Retention(RetentionPolicy.RUNTIME)
public @interface Masked {
    int visibleLast() default 4;
}

Serializer:

public final class MaskedStringSerializer
    extends JsonSerializer<String>
    implements ContextualSerializer {

    private final int visibleLast;

    public MaskedStringSerializer() {
        this(4);
    }

    private MaskedStringSerializer(int visibleLast) {
        this.visibleLast = visibleLast;
    }

    @Override
    public void serialize(
        String value,
        JsonGenerator gen,
        SerializerProvider serializers
    ) throws IOException {
        if (value == null) {
            gen.writeNull();
            return;
        }

        int keep = Math.min(visibleLast, value.length());
        String suffix = value.substring(value.length() - keep);
        gen.writeString("***" + suffix);
    }

    @Override
    public JsonSerializer<?> createContextual(
        SerializerProvider prov,
        BeanProperty property
    ) {
        if (property != null) {
            Masked annotation = property.getAnnotation(Masked.class);
            if (annotation != null) {
                return new MaskedStringSerializer(annotation.visibleLast());
            }
        }

        return this;
    }
}

Usage:

public record CardResponse(
    @Masked(visibleLast = 4)
    @JsonSerialize(using = MaskedStringSerializer.class)
    String cardNumber
) {}

Be careful: masking at serialization is not a substitute for not storing or not retrieving sensitive data.


13. Null Handling

Jackson often handles nulls outside the serializer/deserializer.

Serializer serialize() usually receives non-null value unless custom null handling is involved.

For deserialization, if JSON value is null:

{ "customerId": null }

Jackson may call getNullValue() or set null directly depending context.

You can override:

@Override
public CustomerId getNullValue(DeserializationContext ctxt) throws JsonMappingException {
    return null;
}

But validation should still enforce required fields:

public record CustomerRequest(
    @NotNull CustomerId customerId
) {}

Do not hide required-field policy entirely in deserializer. Keep validation explicit.


14. Error Handling

Bad:

throw new RuntimeException("bad money");

Better:

throw JsonMappingException.from(parser, "amount must be decimal string");

Or:

return (Money) context.handleUnexpectedToken(Money.class, parser);

Good errors:

  • mention expected type/shape
  • avoid leaking secret values
  • include field path via Jackson context when possible
  • preserve cause
  • are stable enough for logging/support

For public API response, map Jackson exceptions to structured error model in higher layer.

Codec should not know HTTP.


15. Unknown Fields in Custom Deserializer

If you ignore unknown fields:

default -> parser.skipChildren();

This is tolerant.

If you reject unknown fields:

default -> throw JsonMappingException.from(
    parser,
    "unknown field for Money: " + fieldName
);

Choose per boundary.

For value objects like Money, strict is often better.

default -> throw JsonMappingException.from(parser, "unknown money field: " + fieldName);

For external provider payloads, tolerant/capture might be better.


16. Avoid Business Logic in Codec

Bad:

if (amount.compareTo(BigDecimal.valueOf(10_000_000)) > 0) {
    routeToManualReview();
}

This is business decision inside deserialization.

Better:

Money money = mapper.readValue(json, Money.class);
riskService.evaluate(money);

Allowed in codec:

  • parse
  • normalize representation
  • construct value object
  • reject structurally invalid value
  • convert external code to internal representation when mapping is representation-level and stable

Not allowed in codec:

  • authorization
  • workflow state transition
  • pricing
  • risk scoring
  • persistence lookup
  • network call
  • feature flag business branching
  • user-specific behavior

17. Custom Enum Deserializer with Raw Preservation

Sometimes unknown enum should be preserved.

public record ProviderStatus(
    String raw,
    Known known
) {
    public enum Known {
        ACTIVE,
        SUSPENDED,
        CLOSED,
        UNRECOGNIZED
    }
}

Deserializer:

public final class ProviderStatusDeserializer
    extends StdDeserializer<ProviderStatus> {

    public ProviderStatusDeserializer() {
        super(ProviderStatus.class);
    }

    @Override
    public ProviderStatus deserialize(
        JsonParser parser,
        DeserializationContext context
    ) throws IOException {
        if (parser.currentToken() != JsonToken.VALUE_STRING) {
            return (ProviderStatus) context.handleUnexpectedToken(
                ProviderStatus.class,
                parser
            );
        }

        String raw = parser.getText();
        ProviderStatus.Known known = switch (raw.trim().toUpperCase(Locale.ROOT)) {
            case "ACTIVE" -> ProviderStatus.Known.ACTIVE;
            case "SUSPENDED" -> ProviderStatus.Known.SUSPENDED;
            case "CLOSED" -> ProviderStatus.Known.CLOSED;
            default -> ProviderStatus.Known.UNRECOGNIZED;
        };

        return new ProviderStatus(raw, known);
    }
}

This is better than mapping unknown to null.


18. Module Design

For a group of value objects:

public final class BoundaryValueModule extends SimpleModule {
    public BoundaryValueModule() {
        super("BoundaryValueModule");

        addSerializer(CustomerId.class, new CustomerIdSerializer());
        addDeserializer(CustomerId.class, new CustomerIdDeserializer());

        addSerializer(Money.class, new MoneySerializer());
        addDeserializer(Money.class, new MoneyDeserializer());

        addDeserializer(ProviderStatus.class, new ProviderStatusDeserializer());
    }
}

Mapper:

ObjectMapper mapper = JsonMapper.builder()
    .addModule(new JavaTimeModule())
    .addModule(new BoundaryValueModule())
    .build();

Governance:

  • module name should be explicit
  • register in one place
  • test module behavior
  • avoid multiple modules fighting same type
  • document global effects
  • version module if used by shared library

19. Serializer vs Mapper Responsibility

Example:

public record PaymentResponse(
    Money total
) {}

Custom serializer can shape Money.

But if response needs:

{
  "amount": "100.00",
  "currency": "IDR",
  "display": "IDR 100.00",
  "localEquivalent": "..."
}

This is not only money serialization. This is response projection.

Use DTO/mapper:

public record MoneyResponse(
    String amount,
    String currency,
    String display
) {}

Mapper:

public MoneyResponse toResponse(Money money, Locale locale) {
    return new MoneyResponse(
        money.amount().toPlainString(),
        money.currency().getCurrencyCode(),
        moneyFormatter.format(money, locale)
    );
}

Serializer should not depend on request locale unless context is carefully controlled.


20. Custom Deserializer and Validation

Deserializer can ensure structural validity. Jakarta Validation handles constraint validity.

DTO:

public record CreatePaymentRequest(
    @NotNull Money amount
) {}

Deserializer rejects:

{ "amount": "abc" }

Validation rejects:

{ "amount": null }

Domain rejects:

{ "amount": { "amount": "100.001", "currency": "IDR" } }

depending where scale policy is enforced.

Recommended layering:


21. Testing Custom Codec

21.1 Serializer Test

@Test
void customerId_serializesAsString() throws Exception {
    ObjectMapper mapper = JsonMapper.builder()
        .addModule(new BoundaryValueModule())
        .build();

    String json = mapper.writeValueAsString(new CustomerId("CUS-001"));

    assertThat(json).isEqualTo("\"CUS-001\"");
}

21.2 Deserializer Test

@Test
void customerId_deserializesFromString() throws Exception {
    ObjectMapper mapper = JsonMapper.builder()
        .addModule(new BoundaryValueModule())
        .build();

    CustomerId id = mapper.readValue("\"CUS-001\"", CustomerId.class);

    assertThat(id.value()).isEqualTo("CUS-001");
}

21.3 Invalid Token Test

@Test
void customerId_rejectsObject() {
    ObjectMapper mapper = mapperWithModule();

    assertThatThrownBy(() -> mapper.readValue("""
    { "value": "CUS-001" }
    """, CustomerId.class))
    .isInstanceOf(JsonMappingException.class);
}

21.4 Money Round-Trip

@Test
void money_roundTrip() throws Exception {
    ObjectMapper mapper = mapperWithModule();

    Money input = new Money(new BigDecimal("100.00"), Currency.getInstance("IDR"));

    String json = mapper.writeValueAsString(input);
    Money output = mapper.readValue(json, Money.class);

    assertThat(output).isEqualTo(input);
}

21.5 Unknown Field Policy

@Test
void money_rejectsUnknownField() {
    ObjectMapper mapper = mapperWithModule();

    assertThatThrownBy(() -> mapper.readValue("""
    {
      "amount": "100.00",
      "currency": "IDR",
      "extra": "x"
    }
    """, Money.class))
    .isInstanceOf(JsonMappingException.class)
    .hasMessageContaining("unknown money field");
}

22. Fuzz and Edge Tests

For codec-heavy code, add edge fixtures:

CaseExpected
missing required fieldmapping exception
field nullnull/validation behavior explicit
wrong token typemapping exception
decimal scientific notationaccept/reject explicit
leading/trailing whitespacenormalize/reject explicit
unknown enumpreserve/reject explicit
nested unknown objectskip/reject explicit
huge stringlimit outside/inside parser
invalid Unicodeparser-level failure
array instead objectmapping exception

23. Performance Considerations

Custom codec can be faster or slower depending design.

Fast:

  • token-level parsing
  • no intermediate tree
  • no reflection for helper DTO
  • reusable mapper/module

Slower:

  • read subtree into JsonNode repeatedly
  • convert via string round-trip
  • allocate maps/lists unnecessarily
  • throw exceptions in normal path
  • log per field/item
  • do regex for every field without need

Avoid:

JsonNode node = parser.getCodec().readTree(parser);
String raw = node.toString();
SomeType value = anotherMapper.readValue(raw, SomeType.class);

This is usually unnecessary round-trip.


24. Thread Safety and State

Serializers/deserializers registered in modules may be reused.

Do not store per-request mutable state inside codec instance:

public final class BadDeserializer extends StdDeserializer<X> {
    private final List<String> errors = new ArrayList<>(); // bad
}

Use local variables inside deserialize().

If contextual behavior is needed, use Jackson contextual interfaces and immutable serializer/deserializer instances.


25. Security Considerations

Custom deserializers parse untrusted input.

Checklist:

  • avoid loading arbitrary classes
  • avoid network/database calls
  • avoid path/file access
  • avoid evaluating expressions
  • avoid logging raw secrets
  • enforce max size/depth at parser/input layer
  • reject unexpected token types
  • sanitize error messages
  • preserve raw unknown only when governed
  • do not enable dangerous polymorphic behavior casually

Custom codec should reduce ambiguity, not increase attack surface.


26. Anti-Patterns

26.1 “Deserializer as Service Layer”

Deserializer calls repository, feature flags, workflow engine, or external API.

26.2 “Serializer as View Renderer”

Serializer formats user-specific localized response with permission logic.

Use explicit response mapper.

26.3 “Read Tree Then Manually Everything”

If shape is stable, use helper DTO or databind.

26.4 “Global Module Surprise”

Registering type-wide serializer changes behavior in endpoints/events that did not expect it.

26.5 “Silent Null on Invalid Input”

Returning null on invalid input hides bad payload.

26.6 “Catch Exception and Default”

catch (Exception e) {
    return Money.ZERO;
}

This corrupts data.


27. Decision Matrix

NeedRecommended
type always scalar on wire@JsonValue/@JsonCreator or type module
one property custom shapeproperty-level @JsonSerialize/@JsonDeserialize
all properties of type same shapemodule registration
third-party classmix-in or module
legacy scalar/object inputcustom deserializer
secret maskingDTO projection first, serializer if representation-only
dynamic event payloadenvelope dispatcher + DTO/tree
complex business transformationmapper/domain service
unknown enum preserve rawcustom deserializer/value object
export large dataJsonGenerator + ObjectWriter, not custom serializer alone

28. Mini Case Study: External Amount

Provider sends:

{
  "amount": 100000,
  "currency": "IDR",
  "scale": 2
}

Meaning: 100000 / 10^2 = 1000.00.

Domain wants:

Money(new BigDecimal("1000.00"), IDR)

Deserializer:

public final class MinorUnitMoneyDeserializer extends StdDeserializer<Money> {

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

    @Override
    public Money deserialize(JsonParser parser, DeserializationContext context)
        throws IOException {

        if (parser.currentToken() != JsonToken.START_OBJECT) {
            return (Money) context.handleUnexpectedToken(Money.class, parser);
        }

        Long minorUnits = null;
        String currencyCode = null;
        Integer scale = null;

        while (parser.nextToken() != JsonToken.END_OBJECT) {
            String field = parser.currentName();
            parser.nextToken();

            switch (field) {
                case "amount" -> {
                    if (!parser.currentToken().isNumeric()) {
                        throw JsonMappingException.from(parser, "amount must be integer minor units");
                    }
                    minorUnits = parser.getLongValue();
                }
                case "currency" -> currencyCode = parser.getValueAsString();
                case "scale" -> scale = parser.getIntValue();
                default -> throw JsonMappingException.from(parser, "unknown money field: " + field);
            }
        }

        if (minorUnits == null) {
            throw JsonMappingException.from(parser, "amount is required");
        }
        if (currencyCode == null) {
            throw JsonMappingException.from(parser, "currency is required");
        }
        if (scale == null) {
            throw JsonMappingException.from(parser, "scale is required");
        }

        BigDecimal amount = BigDecimal.valueOf(minorUnits, scale);
        Currency currency = Currency.getInstance(currencyCode);

        try {
            return new Money(amount, currency);
        } catch (IllegalArgumentException ex) {
            throw JsonMappingException.from(parser, ex.getMessage(), ex);
        }
    }
}

This is acceptable representation mapping because provider format uses minor units. Still, business decisions such as fee, rounding, approval, or limits must remain elsewhere.


29. Practice Drill

Implement custom codec for:

public record AccountNumber(String value) {}

Rules:

  • wire format is string
  • must preserve leading zero
  • must match \d{10,16}
  • serialization outputs plain string
  • deserialization rejects number token
  • deserialization rejects blank
  • no normalization except trim
  • error must say account number must be 10-16 digits

Then:

  1. Implement serializer.
  2. Implement deserializer.
  3. Register module.
  4. Write valid string test.
  5. Write number token rejection test.
  6. Write blank rejection test.
  7. Write leading zero preservation test.
  8. Use in request DTO.
  9. Validate null with @NotNull.

30. Summary

Custom serializers and deserializers are powerful because they sit under normal DTO binding.

Mental model:

Custom codec is for representation mismatch. Domain decisions belong outside.

Rules:

  1. Try DTO/annotation/mapper before custom codec.
  2. Keep codec small and deterministic.
  3. Use JsonGenerator for serialization.
  4. Use JsonParser and token checks for deserialization.
  5. Use StdSerializer/StdDeserializer for standard base behavior.
  6. Register locally if behavior is field-specific; use module if type-wide.
  7. Reject unexpected token types explicitly.
  8. Preserve useful error context.
  9. Do not silently default invalid input.
  10. Do not store mutable request state in codec instance.
  11. Test valid, invalid, null, unknown, and round-trip behavior.
  12. Treat global module registration as architecture decision.

Part berikutnya membahas polymorphic deserialization: type info, subtypes, sealed hierarchies, and the security risks of letting JSON choose Java types.


References

Lesson Recap

You just completed lesson 14 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.