Latest post

Polymorphic JSON deserialization with Java sealed interfaces and Jackson

When building a JSON-based API that accepts multiple request types, you often need a strategy for mapping incoming payloads to the appropriate data model. Java sealed interfaces combined with Jackson’s polymorphic type annotations provide a clean way to support multiple strongly typed backend data models.

Problem

Consider an API that receives data processing requests. Each request provides a requestType field that determines which fields are relevant.

All requests share the same lifecycle and processing steps, but each request type corresponds to a different data model with different required and optional fields.

Eg.

  • Account deletion: {"requestId": "request-1234", "requestType": "deleteAccount", "accountId": "1234"}
  • Profile deletion: {"requestId": "request-1234", "requestType": "deleteProfile", "accountId": "1234", "profileId": "profile-1234"}
  • Scoped data deletion: {"requestId": "request-1234", "requestType": "deleteScopedData", "accountId": "1234", "profileId": "profile-1234", "scope": {"deletePurchaseHistory": true, "deleteSubscriptions": false, "deletePreferences": false}}

Approach: Sealed interface + Jackson annotations

Step 1: Define sealed interface with annotations mapping request types to implementations

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;

/**
 * Polymorphic deletion request interface that routes to request-type-specific data models.
 */
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "requestType")
@JsonSubTypes({
    @JsonSubTypes.Type(value = AccountDeletionRequest.class, name = "deleteAccount"),
    @JsonSubTypes.Type(value = ProfileDeletionRequest.class, name = "deleteProfile"),
    @JsonSubTypes.Type(value = ScopedDataDeletionRequest.class, name = "deleteScopedData")
})
public sealed interface DeletionRequest
        permits AccountDeletionRequest, ProfileDeletionRequest, ScopedDataDeletionRequest {
    // Define common method declarations and static methods here.

    /**
     * Throws IllegalArgumentException if value is null or blank.
     */
    static void requireNonBlank(final String value, final String fieldName) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException(fieldName + " must not be blank");
        }
    }
}

Java interfaces allow us to keep core business logic independent of child implementation details, making the system easier to test and extend. Sealed interfaces, available in Java 17+, restrict which classes are allowed to implement the interface via a permits clause.

When combined with Jackson @JsonTypeInfo and @JsonSubTypes annotations which wire the JSON discriminator field (here, requestType) to the appropriate implementation, this enables a polymorphic contract with automatic, compile-time-checked JSON routing to strongly typed implementations.

Step 2: Define concrete implementations as classes or records

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

/**
 * Data model for account deletion requests.
 *
 * Account and request IDs are required, no other fields needed.
 */
@JsonIgnoreProperties(ignoreUnknown = true)
public record AccountDeletionRequest(
    String accountId,
    String requestId
) implements DeletionRequest {
    public AccountDeletionRequest {
        DeletionRequest.requireNonBlank(accountId, "accountId");
        DeletionRequest.requireNonBlank(requestId, "requestId");
    }
}

/**
 * Data model for profile deletion requests.
 *
 * Account, profile, and request IDs are required, no other fields needed.
 */
@JsonIgnoreProperties(ignoreUnknown = true)
public record ProfileDeletionRequest(
    String accountId,
    String profileId,
    String requestId
) implements DeletionRequest {
    public ProfileDeletionRequest {
        DeletionRequest.requireNonBlank(accountId, "accountId");
        DeletionRequest.requireNonBlank(profileId, "profileId");
        DeletionRequest.requireNonBlank(requestId, "requestId");
    }
}

/**
 * Data model for scoped data deletion requests.
 *
 * Used when users want to delete partial data without closing their account.
 */
@JsonIgnoreProperties(ignoreUnknown = true)
public record ScopedDataDeletionRequest(
    String accountId,
    String profileId,
    String requestId,
    DeletionScope scope
) implements DeletionRequest {
    public ScopedDataDeletionRequest {
        DeletionRequest.requireNonBlank(accountId, "accountId");
        DeletionRequest.requireNonBlank(requestId, "requestId");
        if (scope == null) {
            throw new IllegalArgumentException("scope must not be blank");
        }
    }
}

/**
 * Data model for data deletion scope, where must only delete specific categories of data.
 */
public record DeletionScope(
    Boolean deletePreferences,
    Boolean deletePurchaseHistory,
    Boolean deleteSubscriptions
) {}

Step 3: Deserialize input JSON strings to strongly typed data models

import com.fasterxml.jackson.databind.ObjectMapper;

// Note: Prefer to instantiate ObjectMapper once as static final field or singleton and reuse across classes.
// ObjectMapper is thread-safe, and it's expensive to create new instances.
final ObjectMapper mapper = new ObjectMapper();

final DeletionRequest deletionRequest = mapper.readValue(inputJson, DeletionRequest.class);

With Java 21+, the compiler enforces that every permitted subtype in switch statements such as the following are handled, removing a class of potential runtime errors.

switch (deletionRequest) {
    case AccountDeletionRequest -> System.out.println("Delete account " + deletionRequest.accountId());
    case ProfileDeletionRequest -> System.out.println("Delete profile " + deletionRequest.profileId());
    case ScopedDataDeletionRequest -> System.out.println("Delete scoped data for account " + deletionRequest.accountId() + ", scope: " + deletionRequest.scope());
}

When to apply this pattern

Sealed interfaces and Jackson polymorphism are helpful when:

  • Your JSON payloads share a discriminator field but have different schemas per type
  • You want a closed set of permitted subtypes
  • You want subtypes to be strongly typed, each potentially requiring its own validation logic

Pros and cons vs common alternatives

Strategy Pros Cons
Sealed interface + Jackson polymorphism
  • Supports strongly typed subtypes with different required and allowed fields
  • Easily add subtypes with minimal code changes
  • Compile-time safety
  • Clean pattern matching without casting
  • Requires all subtypes to be defined in same module
  • Requires Java 17+
Single class with nullable fields
  • Simple
  • Works pre-Java 17
  • Runtime null checks everywhere
  • No compile-time safety
  • Unclear which fields apply to each type
  • More messy to enforce validations on subtypes
Inheritance with parent abstract class
  • Familiar OOP pattern
  • Subclasses can live anywhere
  • Works pre-Java 17
  • Anyone can subclass, no compile-time restriction on subtypes
  • Explicit code needed
@JsonAnySetter with Map<String, Object>
  • Maximum flexibility
  • Allows for unknown schemas
  • Works pre-Java 17
  • Zero type safety, accepts all inputs
  • Validation is manual and error-prone
Enum and factory method
  • Explicit type registry (as with sealed interface)
  • More flexible, full control over deserialization
  • Works pre-Java 17
  • More boilerplate code than sealed interface + annotations
  • No compile-time validation of correct/complete routing

Key takeaways

  1. sealed provides a closed type hierarchy - the compiler knows every possible implementation.
  2. Jackson’s @JsonTypeInfo simplifies routing deserialization to the appropriate type based on a discriminator field.
  3. Java records minimize boilerplate for immutable, validated data models.
  4. Pattern matching with switch over sealed types provides compile-time validation validates that every request type is handled.

Sealed interfaces with Jackson polymorphism are especially effective for API contracts where the full schema is owned by your team, and reduces the effort to add new subtypes.