Spring BootでREST APIを書いていると、「LocalDateTimeが配列で返ってくる」「nullフィールドを消したい」「フロントがsnake_caseを要求してくる」みたいな場面に必ず遭遇しますよね。この記事では、Jacksonの設定をどのレイヤーで書けばいいのか、定番のアノテーションとObjectMapperのカスタマイズまでをまとめておきます。Spring Boot 3.x / Java 17 前提です。

Jackson設定の3つのレイヤー

Spring Bootは spring-boot-starter-web を入れると、Jacksonが自動構成され、ObjectMapper がBeanとして登録されます。設定する場所はおおまかに3つあります。

  • application.ymlspring.jackson.* プロパティ(アプリ全体)
  • アノテーション(DTOクラス単位・フィールド単位)
  • Jackson2ObjectMapperBuilderCustomizer などのBean定義(プログラム的に拡張)

全体に効かせたい挙動はYAMLで、特定のフィールドだけ変えたい場合はアノテーションで、と使い分けるのが基本方針です。

application.ymlでまとめて設定する

まずはYAMLでよく使う設定を見ておきます。これだけでも実務の8割は片付きます。

spring:
  jackson:
    date-format: yyyy-MM-dd HH:mm:ss
    time-zone: Asia/Tokyo
    property-naming-strategy: SNAKE_CASE
    default-property-inclusion: non_null
    serialization:
      write-dates-as-timestamps: false
    deserialization:
      fail-on-unknown-properties: false

write-dates-as-timestamps: false を入れておくと、LocalDateTime[2026,5,24,...] のような数値配列ではなくISO-8601文字列で出力されるようになります。これに気付かず最初ハマる人が多い設定です。

日付・時刻のフォーマット

LocalDateTimeが配列で返ってくる問題

spring.jackson.serialization.write-dates-as-timestamps を設定し忘れると、LocalDateTime[2026,5,24,10,30,15] のような数値配列でレスポンスされます。原因は JavaTimeModuleSerializationFeature.WRITE_DATES_AS_TIMESTAMPS のデフォルト(true)に従って数値出力するためです。Spring Boot 3.xの自動構成では false に倒されますが、ObjectMapper を独自Bean定義で置き換えた場合や、テスト用に JsonMapper.builder() から組み立てている場合は再発します。YAMLで write-dates-as-timestamps: false、もしくは前述の Jackson2ObjectMapperBuilderCustomizerfeaturesToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) を明示しておくのが安全です。

Java 8の時刻型は jackson-datatype-jsr310JavaTimeModule で扱われます。Spring Boot 3.xなら自動登録されているので、依存を追加しなくてもそのまま動きます。

フィールド単位でパターンを変えたい場合は @JsonFormat を使います。

public record OrderResponse(
    Long id,
    @JsonFormat(pattern = "yyyy-MM-dd")
    LocalDate orderDate,
    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ssXXX", timezone = "Asia/Tokyo")
    ZonedDateTime createdAt
) {}

ZonedDateTime を使う場合は timezone 属性も指定しておくと、出力先のオフセットが安定します。

nullフィールドを除外する

JsonInclude モード早見表

モード除外対象よくある使いどころ
ALWAYS除外しない(デフォルト)全フィールドを返す内部API
NON_NULLnull のみ一般的なREST APIレスポンス
NON_ABSENTnullOptional.empty()Optional を多用するDTO
NON_EMPTYnull/空文字/空コレクション/空Optional一覧APIで空配列を返したくないとき
NON_DEFAULT型のデフォルト値(0/false等)プリミティブを使う構造体。意図せぬ消失に注意

レスポンスにnullを含めたくないケースはとても多いです。粒度別に使い分けましょう。

@JsonInclude(JsonInclude.Include.NON_NULL)
public record UserResponse(
    Long id,
    String name,
    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    List<String> roles,
    String email
) {}
  • NON_NULL はnullだけ除外
  • NON_EMPTY は空文字・空コレクション・空Optionalも除外
  • NON_DEFAULT はプリミティブのデフォルト値(0false)も除外されるので扱いに注意

グローバルにかけたい場合は、先ほどの default-property-inclusion: non_null で十分です。

snake_case にしたいとき

フロントエンドが created_at のようなsnake_caseを期待することはよくあります。グローバルなら property-naming-strategy: SNAKE_CASE 一発、特定のフィールドだけ別名にしたければ @JsonProperty で上書きします。

public record ArticleResponse(
    Long id,
    String title,
    @JsonProperty("author")
    String authorName,
    LocalDateTime publishedAt
) {}

デシリアライズ側にも同じルールが適用されるので、リクエストもsnake_caseで受けられます。

Jackson2ObjectMapperBuilderCustomizer で拡張する

YAMLで表現できない設定や、独自モジュールの登録には Jackson2ObjectMapperBuilderCustomizer を使います。ObjectMapper を丸ごとBean定義で置き換える方法もありますが、Spring Bootのデフォルト挙動を巻き戻してしまうのでおすすめしません。

@Configuration
public class JacksonConfig {
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer customizer() {
        return builder -> builder
            .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .serializationInclusion(JsonInclude.Include.NON_NULL)
            .modulesToInstall(new MoneyModule());
    }
}

この方式なら、Spring Bootが組み立てた ObjectMapper に差分だけを足せます。

カスタム Serializer / Deserializer

Mixinで他人のクラスにアノテーションを足す

サードパーティ製クラスのように直接アノテーションを書けない型には Mixin が便利です。addMixIn で擬似的にアノテーションを付与できます。

abstract class ExternalDtoMixin {
    @JsonProperty("created_at")
    abstract LocalDateTime getCreatedAt();
}

@Bean
public Jackson2ObjectMapperBuilderCustomizer mixinCustomizer() {
    return builder -> builder.mixIn(ExternalDto.class, ExternalDtoMixin.class);
}

@JsonViewでレスポンスを出し分ける

同じDTOを管理画面と公開APIで出し分けたい場合は @JsonView を使うと、controllerメソッドのアノテーションだけでフィールド粒度の制御ができます。

public class Views {
    public interface Public {}
    public interface Admin extends Public {}
}

public record UserResponse(
    @JsonView(Views.Public.class) Long id,
    @JsonView(Views.Public.class) String name,
    @JsonView(Views.Admin.class) String email
) {}

@GetMapping("/users/{id}")
@JsonView(Views.Public.class)
public UserResponse get(...) { ... }

@JsonAliasで複数のキー名を受け取る

外部APIや旧バージョンとの互換で、入力JSONのキー名がブレることがあります。@JsonAlias を付けておけば、出力は1つの名前に固定したまま複数の入力名を受け付けられます。

public record ImportRow(
    @JsonAlias({"user_id", "userID"}) Long userId,
    String name
) {}

循環参照を @JsonManagedReference / @JsonBackReference で切る

JPAエンティティで双方向関連をそのままシリアライズすると StackOverflowError になります。親側に @JsonManagedReference、子側に @JsonBackReference を付けるか、片側を @JsonIgnore するのが定石です。DTO層を介す設計のほうが本来は健全ですが、PoC段階ではこのアノテーションで一旦回避できます。

ポリモーフィックな型を @JsonTypeInfo で扱う

抽象クラス/インターフェース型のフィールドを正しくシリアライズ・デシリアライズしたい場合は @JsonTypeInfo@JsonSubTypes を組み合わせます。

@JsonTypeInfo(use = Id.NAME, property = "type")
@JsonSubTypes({
    @Type(value = CreditCard.class, name = "credit_card"),
    @Type(value = BankTransfer.class, name = "bank_transfer")
})
public sealed interface Payment permits CreditCard, BankTransfer {}

JSON側に "type": "credit_card" の判別キーが入るので、デシリアライズ時にも正しい具象型に復元されます。

通貨表記や独自Enumなど、標準では表現できない変換は JsonSerializer<T> / JsonDeserializer<T> を実装します。例として BigDecimal¥1,000 形式で出力するシリアライザを書いてみます。

public class YenSerializer extends JsonSerializer<BigDecimal> {
    private static final NumberFormat FORMAT =
        NumberFormat.getCurrencyInstance(Locale.JAPAN);

    @Override
    public void serialize(BigDecimal value, JsonGenerator gen,
                          SerializerProvider serializers) throws IOException {
        gen.writeString(FORMAT.format(value));
    }
}

フィールド単位で使うなら @JsonSerialize(using = YenSerializer.class) を付けます。アプリ全体で適用したい場合は SimpleModule に登録して、先ほどの modulesToInstall で渡します。

Enumを表示用ラベルから逆変換したいときは JsonDeserializer<T> を実装します。

public class StatusDeserializer extends JsonDeserializer<Status> {
    @Override
    public Status deserialize(JsonParser p, DeserializationContext ctx)
            throws IOException {
        String label = p.getText();
        return Status.fromLabel(label);
    }
}

出力したくないフィールドと読み取り専用

パスワードや内部IDのように、レスポンスに出したくないフィールドは @JsonIgnore で除外できます。さらに、入力としては受け取りたいが出力しないケースには @JsonProperty(access = ...) が便利です。

public record SignupRequest(
    String email,
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    String password
) {}

WRITE_ONLY ならレスポンス出力時に消え、READ_ONLY ならリクエスト受信時に無視されます。秘匿情報がうっかりレスポンスに混ざる事故を防げます。

未知プロパティをどう扱うか

リクエストJSONに知らないフィールドが含まれたとき、デフォルトでは UnrecognizedPropertyException が飛びます。クライアントの拡張に寛容にしたいAPIでは無効化しておくのが一般的です。

YAMLで deserialization.fail-on-unknown-properties: false を入れるか、クラス単位で @JsonIgnoreProperties(ignoreUnknown = true) を付けます。逆に内部APIや厳格に整合性を取りたいエンドポイントでは有効のままにしておくと、契約違反を早期に検知できます。

エラーレスポンスの形式を整えたい場合は、Spring BootのProblem Details (RFC 9457) でエラーレスポンスを統一する もあわせてどうぞ。また、CRUDの基本実装は Spring BootでREST APIを作るチュートリアル、DTO設計は MapStructでDTOマッピングを書く を参照してください。

まとめ

JSONの形を整える作業は、YAMLでざっくり全体方針を決めて、DTOにアノテーションでピンポイント調整を加えるのが王道です。それでも足りなくなったら Jackson2ObjectMapperBuilderCustomizer でモジュールを足したり、カスタム Serializer を書いたりすればよく、いきなり ObjectMapper をBeanで置き換える必要はほぼありません。設定が効かないときは「どのレイヤーで上書きされているか」を順に追っていくと、たいてい原因にたどり着けます。

よくある質問 (FAQ)

Q. application.yml で snake_case 設定したのに効きません

ObjectMapper をBeanで丸ごと差し替えていると spring.jackson.* の自動構成が無効になります。Jackson2ObjectMapperBuilderCustomizer でカスタムするか、置き換えた ObjectMapper 側に setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) を明示してください。

Q. LocalDateTime が [2026,5,24,...] の配列で返るのを直すには

spring.jackson.serialization.write-dates-as-timestamps: false を設定するか、SerializationFeature.WRITE_DATES_AS_TIMESTAMPSfeaturesToDisable で無効化します。Spring Boot 3.xのデフォルトでは無効化されていますが、独自ObjectMapperでは再発します。

Q. @JsonIgnore@JsonProperty(access = WRITE_ONLY) はどう違いますか

@JsonIgnore は読み書き両方で無視されます。WRITE_ONLY はリクエスト受信時には読み取り、レスポンス出力時には消えます。パスワードのような『受け取るが返さない』フィールドに最適です。

Q. @JsonFormat のタイムゾーン指定が反映されません

LocalDateTime はタイムゾーン情報を持たないため timezone 属性は無視されます。タイムゾーンを保持したい場合は ZonedDateTimeOffsetDateTime を使ってください。