Series MapLesson 16 / 32
Build CoreOrdered learning track

Learn Java Data Mapper Json Xml Validation Part 016 Jackson Modules Java Time Records Optional

12 min read2356 words
PrevNext
Lesson 1632 lesson track0718 Build Core

title: Learn Java Data Mapper, JSON/XML Processing & Validation - Part 016 description: Jackson modules untuk Java Time, records, Optional/JDK8 types, parameter names, Afterburner/Blackbird, custom modules, module registration governance, and production mapper profiles. series: learn-java-data-mapper-json-xml-validation seriesTitle: Learn Java Data Mapper, JSON/XML Processing & Validation order: 16 partTitle: Jackson Modules: Java Time, Records, Optional, Parameter Names, Afterburner/Blackbird Concepts tags:

  • java
  • jackson
  • modules
  • java-time
  • records
  • optional
  • parameter-names
  • afterburner
  • blackbird
  • objectmapper
  • serialization
  • deserialization date: 2026-06-29

Part 016 — Jackson Modules: Java Time, Records, Optional, Parameter Names, Afterburner/Blackbird Concepts

Target skill: mampu mengelola Jackson modules sebagai bagian dari platform serialization policy: kapan register module, module mana wajib, bagaimana module memengaruhi contract, dan bagaimana menghindari drift antar service/test/runtime.

Jackson core/databind tidak berdiri sendiri. Banyak kemampuan production datang dari modules.

Examples:

  • jackson-datatype-jsr310 untuk java.time
  • jackson-datatype-jdk8 untuk JDK8 types seperti Optional
  • jackson-module-parameter-names untuk constructor parameter names
  • jackson-module-afterburner dan jackson-module-blackbird untuk performance optimization
  • custom module untuk value objects, serializers, deserializers, subtype registration
  • framework-provided modules di Spring Boot/Quarkus/Micronaut/Jakarta stacks

Mental model:

ObjectMapper configuration is platform policy. Modules are how that policy becomes executable.


1. Kaufman Deconstruction

Subskill module mastery:

SubskillKemampuan
Understand module roleTahu module menambah serializer, deserializer, introspector, subtype, modifier
Register modules explicitlyMembangun mapper profile yang reproducible
Support Java TimeMenangani Instant, LocalDate, OffsetDateTime, etc.
Support modern Java shapesRecords, constructor parameters, immutable classes
Handle JDK8 typesOptional and related types if used at boundary
Use performance modules carefullyAfterburner/Blackbird concepts and measurement
Build custom modulesGroup custom codecs and subtype registration
Avoid module driftTest mapper profile in app/test/tools
Version mapper policyTreat changes as contract-impacting
Audit framework defaultsDo not assume all runtimes configure mapper identically

2. What Is a Jackson Module?

A Jackson module packages extensions for ObjectMapper.

A module can register:

  • serializers
  • deserializers
  • key serializers/deserializers
  • abstract type mappings
  • subtype registrations
  • bean serializer modifiers
  • bean deserializer modifiers
  • annotation introspectors
  • value instantiators

Simple example:

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

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

A module changes how mapper interprets types. Therefore it can change wire contract.


3. Module Governance Principle

Do not let every team/class create ad-hoc mapper config.

Bad:

ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);

repeated differently in multiple places.

Better:

public final class JsonMapperFactory {
    private JsonMapperFactory() {}

    public static ObjectMapper strictApiMapper() {
        return JsonMapper.builder()
            .addModule(new JavaTimeModule())
            .addModule(new BoundaryValueModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
            .build();
    }

    public static ObjectMapper tolerantEventMapper() {
        return JsonMapper.builder()
            .addModule(new JavaTimeModule())
            .addModule(new BoundaryValueModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
            .build();
    }
}

Even better in framework apps: one central customization policy, with tests.


4. Java Time Module

Modern Java uses java.time.

Common types:

TypeMeaning
Instantmachine timestamp
OffsetDateTimetimestamp with offset
ZonedDateTimedate-time with timezone rules
LocalDateTimelocal date-time without offset
LocalDatebusiness/calendar date
YearMonthbilling/statement period
Durationmachine duration
Periodcalendar period

Register module:

ObjectMapper mapper = JsonMapper.builder()
    .addModule(new JavaTimeModule())
    .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
    .build();

Example DTO:

public record AuditEvent(
    String eventId,
    Instant occurredAt,
    LocalDate businessDate
) {}

Output:

{
  "eventId": "evt-001",
  "occurredAt": "2026-06-29T03:00:00Z",
  "businessDate": "2026-06-29"
}

4.1 Timestamp Shape Policy

If WRITE_DATES_AS_TIMESTAMPS is enabled, output can become numeric/array-like depending type/configuration.

For API/event contracts, prefer ISO strings unless there is a strong reason.

Policy:

All boundary date-time values are ISO-8601 strings.
Instant uses UTC Z form.
LocalDate uses yyyy-MM-dd.
No locale-specific date strings in machine contracts.

4.2 Field-Specific Exceptions

Use @JsonFormat only for local exception:

public record LegacyReportRequest(
    @JsonFormat(pattern = "dd/MM/yyyy")
    LocalDate reportDate
) {}

But do not let legacy format leak into global mapper.


5. Records

Java records are excellent DTO candidates because they are immutable data carriers with explicit components.

public record CustomerResponse(
    String customerId,
    String fullName
) {}

Jackson 2.12+ added record support in databind. In modern Jackson usage, records generally work well when component names are available and constructors align.

For explicit contract names:

public record CustomerResponse(
    @JsonProperty("customer_id")
    String customerId,

    @JsonProperty("full_name")
    String fullName
) {}

Validation fits naturally:

public record CreateCustomerRequest(
    @NotBlank String fullName,
    @Email String email
) {}

5.1 Records Are DTO-Friendly, Not Always Domain-Friendly

Records are good when:

  • object is transparent data carrier
  • all fields are known at construction
  • immutability is desired
  • mapping is straightforward
  • equality by components is intended

Records may be awkward when:

  • object has lifecycle/state transitions
  • invariants require behavior-heavy model
  • lazy relationships exist
  • identity semantics differ from component equality
  • mutation/partial construction is required

Use records heavily at boundary. Use domain model shape deliberately.


6. Parameter Names Module

For immutable classes with constructors, Jackson needs to know constructor parameter names unless annotations are explicit.

Class:

public final class CustomerDto {
    private final String customerId;
    private final String fullName;

    public CustomerDto(String customerId, String fullName) {
        this.customerId = customerId;
        this.fullName = fullName;
    }

    public String customerId() {
        return customerId;
    }

    public String fullName() {
        return fullName;
    }
}

With parameter names available at compile time and module support, Jackson can bind constructor args.

Register:

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

Alternative explicit annotation:

@JsonCreator
public CustomerDto(
    @JsonProperty("customerId") String customerId,
    @JsonProperty("fullName") String fullName
) {
    this.customerId = customerId;
    this.fullName = fullName;
}

Production recommendation:

SituationApproach
public DTO recordrecord components + annotations if needed
immutable classexplicit @JsonCreator/@JsonProperty or parameter names module
shared libraryprefer explicit annotations for clarity
build may omit -parametersexplicit annotations safer
framework-managed mapperverify module/defaults in tests

7. JDK8 Module and Optional

Optional in DTOs is controversial.

Example:

public record CustomerResponse(
    String customerId,
    Optional<String> middleName
) {}

With JDK8 datatype support, Jackson can handle Optional.

But in many APIs, prefer plain nullable field plus explicit null/absence policy.

Why avoid Optional fields in DTOs?

  • Optional was primarily designed as return type, not field type
  • it complicates validation
  • it complicates JSON semantics
  • absence vs null may still be unclear
  • many frameworks/tools handle it inconsistently

Better response DTO:

@JsonInclude(JsonInclude.Include.NON_NULL)
public record CustomerResponse(
    String customerId,
    String middleName
) {}

For patch semantics, use a presence-aware type, not Optional.

public record CustomerPatchRequest(
    PatchField<String> middleName
) {}

7.1 When Optional Is Acceptable

  • internal DTOs
  • method returns
  • configuration objects
  • when mapper profile explicitly supports it
  • when contract semantics are documented

Do not use Optional as a lazy substitute for absence/null design.


8. findAndAddModules()

Jackson can discover modules via service loader:

ObjectMapper mapper = JsonMapper.builder()
    .findAndAddModules()
    .build();

This is convenient, but has production trade-offs.

Pros:

  • less boilerplate
  • auto-registers modules on classpath
  • useful for apps/framework integration

Cons:

  • behavior changes when dependency changes
  • hidden module drift
  • tests may differ from production
  • startup/classpath differences matter
  • harder to reason about exact policy

For libraries and high-control platforms, explicit registration is often better.

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

For applications, findAndAddModules() may be acceptable if covered by mapper contract tests.


9. Performance Modules: Afterburner and Blackbird Concepts

Jackson performance modules can optimize property access and reduce reflection overhead.

Historically common:

new AfterburnerModule()

Newer JVM-focused optimization:

new BlackbirdModule()

The exact benefit depends on:

  • Java version
  • Jackson version
  • DTO shape
  • access modifiers
  • records vs beans
  • reflection access restrictions
  • payload size
  • CPU vs I/O bottleneck
  • serialization vs deserialization path

Do not add performance modules because they sound fast. Benchmark.

9.1 When to Consider

SituationConsider?
JSON CPU is measured bottleneckyes
high-throughput internal servicemaybe
small public API responseprobably no
I/O/database dominatesunlikely useful
native image / restricted runtimetest carefully
complex custom serializersmeasure
framework already optimizesverify before adding

9.2 Benchmark Rule

Before:

baseline ObjectMapper

After:

ObjectMapper + performance module

Measure:

  • throughput
  • p95/p99 latency
  • allocation rate
  • GC pressure
  • startup impact
  • correctness against golden fixtures

Performance module must not change contract. Test that output stays identical.


10. Custom Module for Boundary Value Objects

Suppose you have:

public record CustomerId(String value) {}
public record AccountNumber(String value) {}
public record Money(BigDecimal amount, Currency currency) {}

Build module:

public final class BoundaryValueModule extends SimpleModule {

    public BoundaryValueModule() {
        super("BoundaryValueModule");

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

        addSerializer(AccountNumber.class, new AccountNumberSerializer());
        addDeserializer(AccountNumber.class, new AccountNumberDeserializer());

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

Register:

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

This centralizes representation policy.


11. Custom Module for Subtype Registration

public final class PaymentSubtypeModule extends SimpleModule {

    public PaymentSubtypeModule() {
        super("PaymentSubtypeModule");

        registerSubtypes(
            new NamedType(CardPaymentMethod.class, "CARD"),
            new NamedType(BankTransferPaymentMethod.class, "BANK_TRANSFER"),
            new NamedType(EWalletPaymentMethod.class, "EWALLET")
        );
    }
}

Use when base type annotation is not desired or subtype registry belongs to boundary layer.

Governance:

  • subtype names are contract values
  • adding subtype requires compatibility review
  • removing subtype is breaking
  • duplicate names must fail tests
  • registry should be discoverable

12. Module Order and Interaction

Module order can matter if multiple modules modify same type/serializer behavior.

Bad smell:

.addModule(new InternalMoneyModule())
.addModule(new ExternalMoneyModule())

Both register serializer for Money.

Which one wins may depend on registration behavior and mapper internals.

Better:

  • separate mapper profiles
  • separate DTO types
  • boundary-specific serializers
  • avoid global conflict for same type

Example:

ObjectMapper publicApiMapper = JsonMapper.builder()
    .addModule(new PublicApiMoneyModule())
    .build();

ObjectMapper providerMapper = JsonMapper.builder()
    .addModule(new ProviderMoneyModule())
    .build();

If same Java type has different wire shapes in different boundaries, global type-wide module is dangerous. Prefer DTO-specific shape.


13. Mapper Profiles

A serious platform often needs multiple mapper profiles.

ProfilePolicy
strict API request mapperfail unknown, strict coercion, validation before domain
public response mapperstable inclusion/naming/date policy
tolerant event consumer mappertolerate/capture unknown fields
audit mapperpreserve raw/sanitized payload
test mappermust match production profile
legacy provider mappercustom date/field/code format
internal cache mapperoptimized, may include type metadata only if trusted

Example:

public enum JsonMapperProfile {
    STRICT_API,
    TOLERANT_EVENT,
    LEGACY_PROVIDER,
    AUDIT
}

Factory:

public final class ObjectMappers {

    public static ObjectMapper create(JsonMapperProfile profile) {
        JsonMapper.Builder builder = JsonMapper.builder()
            .addModule(new JavaTimeModule())
            .addModule(new BoundaryValueModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

        return switch (profile) {
            case STRICT_API -> builder
                .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
                .build();

            case TOLERANT_EVENT -> builder
                .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
                .build();

            case LEGACY_PROVIDER -> builder
                .addModule(new LegacyProviderModule())
                .build();

            case AUDIT -> builder
                .build();
        };
    }
}

Avoid “one mapper to rule them all” if boundaries have conflicting policies.


14. Framework Integration

Frameworks often auto-configure ObjectMapper.

Examples of things frameworks may configure:

  • Java Time support
  • JDK8 datatype support
  • parameter names support
  • Kotlin/Scala modules
  • naming strategy
  • null inclusion
  • unknown property behavior
  • date timestamp behavior
  • customizers from dependency injection
  • HTTP message converters

Do not assume.

Write a test:

@SpringBootTest
class ObjectMapperContractTest {

    @Autowired ObjectMapper objectMapper;

    @Test
    void objectMapper_serializesInstantAsIsoString() throws Exception {
        String json = objectMapper.writeValueAsString(
            new EventResponse(Instant.parse("2026-06-29T03:00:00Z"))
        );

        assertThat(json).contains("2026-06-29T03:00:00Z");
        assertThat(json).doesNotContain("1751166000000");
    }
}

The framework-managed mapper is the one that matters for HTTP behavior.


15. Test Mapper Drift

Common drift:

  • unit tests instantiate new ObjectMapper()
  • app uses framework mapper with modules
  • CLI tool uses another mapper
  • integration test uses findAndAddModules()
  • production adds dependency that auto-registers module
  • serializer/deserializer differs between event producer and consumer

Prevent with shared test fixture:

public final class JsonTestSupport {
    public static ObjectMapper productionLikeMapper() {
        return ObjectMappers.create(JsonMapperProfile.STRICT_API);
    }
}

Better: inject actual mapper in tests where possible.


16. Contract Tests for Modules

16.1 Java Time

@Test
void instant_serializesAsIsoString() throws Exception {
    EventDto dto = new EventDto(Instant.parse("2026-06-29T03:00:00Z"));

    String json = mapper.writeValueAsString(dto);

    assertThat(json).contains("2026-06-29T03:00:00Z");
}

16.2 Record

@Test
void record_deserializes() throws Exception {
    CustomerResponse response = mapper.readValue("""
    {
      "customerId": "CUS-001",
      "fullName": "Ana"
    }
    """, CustomerResponse.class);

    assertThat(response.customerId()).isEqualTo("CUS-001");
}

16.3 Value Module

@Test
void customerId_usesBoundaryValueModule() throws Exception {
    CustomerId id = mapper.readValue("\"CUS-001\"", CustomerId.class);

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

16.4 Mapper Profile

@Test
void strictApiMapper_rejectsUnknownFields() {
    ObjectMapper strict = ObjectMappers.create(JsonMapperProfile.STRICT_API);

    assertThatThrownBy(() -> strict.readValue("""
    {
      "customerId": "CUS-001",
      "extra": "x"
    }
    """, CustomerResponse.class))
    .isInstanceOf(JsonProcessingException.class);
}

17. Modules and Jackson 3

Jackson 3 is a major version line and is not API-compatible with 2.x. Migration requires explicit review of dependencies, package/group changes, module availability, and framework support.

Production implications:

  • do not mix random Jackson 2.x and 3.x dependencies
  • verify framework compatibility
  • review package/import changes
  • run golden contract tests
  • run custom serializer/deserializer tests
  • check module replacements/built-ins
  • benchmark performance if relying on Afterburner/Blackbird
  • stage migration per service/library

Do not treat major Jackson upgrade as dependency hygiene only. It can affect serialization semantics.


18. Dependency Alignment

Jackson has multiple artifacts:

  • jackson-core
  • jackson-annotations
  • jackson-databind
  • datatype modules
  • dataformat modules
  • integration modules

Keep versions aligned through BOM/dependency management.

Maven example:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.fasterxml.jackson</groupId>
      <artifactId>jackson-bom</artifactId>
      <version>${jackson.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

Gradle platform example:

dependencies {
    implementation(platform("com.fasterxml.jackson:jackson-bom:$jacksonVersion"))
    implementation("com.fasterxml.jackson.core:jackson-databind")
    implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
}

Avoid version soup.


19. Module Registration Anti-Patterns

19.1 new ObjectMapper() Everywhere

Leads to inconsistent behavior.

19.2 findAndAddModules() in Library Code

Library behavior changes based on host classpath. Prefer accepting mapper from caller or explicit config.

19.3 Global Serializer for Context-Specific Type

Same Money type may need different wire shape for public API and provider integration. Global module can break one boundary.

19.4 Performance Module Without Benchmark

Optimization without measurement can add risk without benefit.

19.5 Test Mapper Not Equal Production Mapper

Golden tests become false confidence.

19.6 Module Registration in Random Configuration Classes

Hard to audit and reason about.


20. Practical Production Mapper

public final class ProductionJson {

    private ProductionJson() {}

    public static ObjectMapper strictApiMapper() {
        return JsonMapper.builder()
            .addModule(new JavaTimeModule())
            .addModule(new ParameterNamesModule())
            .addModule(new BoundaryValueModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
            .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
            .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
            .build();
    }

    public static ObjectMapper tolerantEventMapper() {
        return JsonMapper.builder()
            .addModule(new JavaTimeModule())
            .addModule(new ParameterNamesModule())
            .addModule(new BoundaryValueModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
            .build();
    }
}

Note: exact features depend on your contract. Do not copy blindly.


21. Module as Platform Boundary

Think of modules as part of platform architecture.

If module policy is inconsistent, every boundary can behave differently.


22. Production Checklist

Before changing modules:

  • Which mapper profile is affected?
  • Does this change serialization, deserialization, or both?
  • Is the module global or boundary-specific?
  • Are date/time outputs unchanged?
  • Are records/constructors still deserializable?
  • Are unknown fields handled as expected?
  • Are custom value objects still encoded correctly?
  • Are polymorphic subtypes unchanged?
  • Are golden contract tests updated?
  • Is dependency version aligned?
  • Does framework-managed mapper receive this module?
  • Do tests use the same mapper as production path?
  • If performance module added, is benchmark attached?
  • If Jackson major version changed, is migration reviewed?

23. Mini Case Study: Payment Platform Mapper Profiles

23.1 Strict API Request Mapper

Used for client commands.

Policy:

  • reject unknown fields
  • ISO date/time strings
  • value object module
  • no default typing
  • strict enum policy
  • no global tolerance
ObjectMapper strictApi = JsonMapper.builder()
    .addModule(new JavaTimeModule())
    .addModule(new PaymentValueModule())
    .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
    .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
    .build();

23.2 Tolerant Provider Event Mapper

Used for third-party webhooks.

Policy:

  • tolerate unknown fields
  • preserve extension map when modeled
  • custom provider date/code module
  • sanitize raw payload before audit
  • route unknown event type
ObjectMapper providerEvent = JsonMapper.builder()
    .addModule(new JavaTimeModule())
    .addModule(new ProviderMoneyModule())
    .addModule(new ProviderStatusModule())
    .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
    .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
    .build();

23.3 Export Mapper

Used for generated export files.

Policy:

  • stable property ordering if required
  • ISO date/time
  • explicit null inclusion
  • no provider-tolerant deserialization concerns
  • generator/ObjectWriter path tested

Different boundary, different mapper profile.


24. Practice Drill

Build mapper configuration for these use cases:

  1. Public REST API request.
  2. Public REST API response.
  3. Kafka event consumer.
  4. Legacy provider webhook.
  5. CSV-to-JSON export tool.
  6. Audit payload sanitizer.

For each, define:

  • modules
  • unknown field policy
  • date-time format
  • enum policy
  • value object serializers/deserializers
  • null inclusion
  • polymorphism policy
  • whether findAndAddModules() is allowed
  • contract tests required

25. Summary

Jackson modules are not just dependencies. They are executable serialization policy.

Mental model:

A mapper without module governance is a hidden platform fork.

Rules:

  1. Register modules intentionally.
  2. Use Java Time module/config for modern date-time types.
  3. Use records as boundary DTOs when they match contract shape.
  4. Use parameter names support or explicit creator annotations for immutable classes.
  5. Be cautious with Optional in DTO fields.
  6. Avoid mapper drift between app/test/tooling.
  7. Prefer explicit module registration in controlled platforms.
  8. Use performance modules only after benchmark.
  9. Put custom value serializers/deserializers in named modules.
  10. Treat Jackson major upgrade as contract-impacting.
  11. Align Jackson dependency versions with BOM/dependency management.
  12. Test mapper profiles with golden fixtures.

Part berikutnya covers Jackson 2.x to 3.x migration: package/group changes, compatibility risk, custom codec impact, framework support, and safe migration strategy.


References

Lesson Recap

You just completed lesson 16 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.