import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonTypeInfo.As;
import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;
import java.math.BigDecimal;
@JsonTypeInfo(
use = Id.NAME,
include = As.EXISTING_PROPERTY,
property = "type",
visible = true
)
@JsonSubTypes({
@JsonSubTypes.Type(value = PaymentMethod.Card.class, name = "card"),
@JsonSubTypes.Type(value = PaymentMethod.BankTransfer.class, name = "bank_transfer"),
@JsonSubTypes.Type(value = PaymentMethod.Wallet.class, name = "wallet")
})
public sealed interface PaymentMethod
permits PaymentMethod.Card, PaymentMethod.BankTransfer, PaymentMethod.Wallet {
String type();
BigDecimal amount();
record Card(BigDecimal amount, String panLast4, String network) implements PaymentMethod {
@Override
public String type() {
return "card";
}
}
record BankTransfer(BigDecimal amount, String iban, String bic) implements PaymentMethod {
@Override
public String type() {
return "bank_transfer";
}
}
record Wallet(BigDecimal amount, String provider, String accountRef) implements PaymentMethod {
@Override
public String type() {
return "wallet";
}
}
}
import com.fasterxml.jackson.databind.exc.InvalidTypeIdException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/payments")
public class PaymentController {
private final PaymentGateway gateway;
public PaymentController(PaymentGateway gateway) {
this.gateway = gateway;
}
@PostMapping
public ResponseEntity<Map<String, Object>> charge(@RequestBody PaymentMethod method) {
String reference = switch (method) {
case PaymentMethod.Card card ->
gateway.chargeCard(card.panLast4(), card.network(), card.amount());
case PaymentMethod.BankTransfer bt ->
gateway.initiateTransfer(bt.iban(), bt.bic(), bt.amount());
case PaymentMethod.Wallet w ->
gateway.chargeWallet(w.provider(), w.accountRef(), w.amount());
};
return ResponseEntity.ok(Map.of(
"type", method.type(),
"amount", method.amount(),
"reference", reference
));
}
@ExceptionHandler(InvalidTypeIdException.class)
public ResponseEntity<Map<String, String>> onUnknownType(InvalidTypeIdException ex) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "unsupported payment type: " + ex.getTypeId()));
}
}
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
@Configuration
public class JacksonConfig {
@Bean
@Primary
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
// Reject payloads carrying fields no record component maps to.
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true);
mapper.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, false);
return mapper;
}
}
This snippet shows how a single JSON type discriminator can be mapped onto a Java sealed interface so that the compiler enforces exhaustiveness while Jackson handles the wire format. The domain in PaymentMethod sealed type models the mutually exclusive shapes a payment can take: a card, a bank transfer, or a wallet. Because the interface is sealed, the permitted set is fixed and known to the compiler, which is what makes the downstream switch in PaymentController safe without a default branch.
The key annotation is @JsonTypeInfo with use = Id.NAME and include = As.EXISTING_PROPERTY, which tells Jackson to read a discriminator from a real field named type rather than injecting a synthetic wrapper. The @JsonSubTypes block then binds each string value (card, bank_transfer, wallet) to a concrete record implementation. Using records keeps each variant immutable and gives Jackson a canonical constructor to bind against via @JsonCreator-style component matching, so no setters or no-arg constructors are needed. Each variant also re-declares the type() accessor returning a fixed value, satisfying the EXISTING_PROPERTY contract so the discriminator round-trips on serialization.
A subtle point is visible = true: without it Jackson would consume the type field for routing and never pass it to the record component, causing a binding failure. Marking it visible lets the same property serve both as the discriminator and as ordinary data.
In PaymentController, the @RequestBody PaymentMethod method parameter triggers this whole mechanism during request binding, and Spring's MappingJackson2HttpMessageConverter reuses the same configured ObjectMapper. The handler then pattern-matches with switch over the sealed type; because every permitted subtype is covered, the code is exhaustive and adding a new variant becomes a compile error until every branch is updated — turning a runtime ClassCastException risk into a build-time guarantee.
The main trade-off is coupling the JSON contract to the type hierarchy: renaming a discriminator value is a breaking API change, and unknown type values throw InvalidTypeIdException, which @ExceptionHandler maps to a 400. This pattern fits well when variants are genuinely closed and small; for open-ended plugin-style types a registry-based deserializer is a better fit.
Related snips
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
import axios from 'axios';
export type NormalizedErrors = {
fields: Record<string, string>;
formLevel: string | null;
};
Frontend: normalize and display server validation errors
module Paginatable
extend ActiveSupport::Concern
MAX_PER_PAGE = 100
DEFAULT_PER_PAGE = 25
API Pagination Headers (Link + Total)
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
Laravel form requests for validation
package api
import (
"encoding/json"
"errors"
"io"
Streaming JSON decoding with DisallowUnknownFields
Share this code
Here's the card — post it anywhere.