LocalDate.plusDaysで日付が変わらない理由と戻り値の代入を解説する記事のアイキャッチ。

実装・エラー解決

LocalDate.plusDaysで日付が変わらない理由|戻り値を代入する必要がある

AIによる要約

LocalDateは不変オブジェクトです。plusDaysは元のdateを書き換えず、計算後の新しいLocalDateを返します。そのためdate.plusDays(1)だけではdateは変わりません。date = date.plusDays(1)のように戻り値を受け取ります。月末やうるう年はAPIが処理しますが、営業日、締日、タイムゾーンは別の業務ルールなので、単純な日数加算と混同しないことが重要です。

この記事はXへ投稿したJavaクイズの詳しい解説です。まず問題を解きたい方は、Xの元ポストを開くことができます。

新人SE
新人SE
plusDays(1)を呼んだのに、出力した日付が昨日のままです。
ポンコツSE
ポンコツSE
LocalDateは元の値を変更しません。計算結果として返る新しいLocalDateを変数へ代入してください。

日付を1日進めるコードは、バッチ対象日、請求期限、検索範囲などで頻繁に使います。plusDaysという名前から、変数の中身がその場で更新されるように感じますが、LocalDateは不変です。

戻り値を受け取らないコードはコンパイルも通り、例外も出ません。そのため障害時に気付きにくく、ループなら同じ日付を処理し続ける可能性もあります。日付APIの契約と業務日付のルールを分けて確認します。

この記事のポイント

  • LocalDateは不変オブジェクト
  • plusDaysは新しいLocalDateを返す
  • 戻り値を代入しないと元の日付は変わらない
  • 月末・うるう年はAPIが処理する
  • 営業日や締日は別の業務ルールとして実装する

まずはXのJavaクイズを確認する

LocalDateへplusDays(1)を呼んだ後に元の変数を出力した結果を問うJavaクイズ画像。
1日後、元の日付、コンパイルエラーのどれになるでしょうか。
LocalDate date = LocalDate.of(2026, 8, 13);

date.plusDays(1);

System.out.println(date);

答えは2026-08-13です。plusDaysは翌日のLocalDateを返しますが、元のdateは変更しません。

LocalDateは不変でplusDaysの戻り値を代入しないため元の日付のままと説明する画像。
答えは元の2026-08-13です。計算結果を受け取っていません。

元の投稿は、Xの出題ポストと回答ポストでも確認できます。

LocalDateは不変オブジェクト

不変オブジェクトは、作成後に内部の値が変わりません。LocalDateの年・月・日はsetterで変更できず、加算や変更メソッドは新しいインスタンスを返します。

LocalDate original = LocalDate.of(2026, 8, 13);
LocalDate next = original.plusDays(1);

System.out.println(original); // 2026-08-13
System.out.println(next);     // 2026-08-14

文字列のreplaceやtoUpperCaseでも同じ考え方があります。戻り値があるメソッドを呼んだら、その値を使っているか確認する習慣を付けます。

正しくは戻り値を代入する

同じ変数名で次の日付を使うなら、戻り値を再代入します。元の日付も必要なら別変数へ保持します。

date = date.plusDays(1);

// または意図が分かる別名にする
LocalDate nextDate = date.plusDays(1);

メソッドチェーンもすべて新しい値を返します。途中の結果を読みやすくする必要があれば、1行へ詰め込まず変数を分けます。

ループで戻り値を捨てると処理が進まない

日次バッチで開始日から終了日までを1日1回ずつ処理します。current.plusDays(1) の戻り値を捨てると current が同じままなので、複数日の範囲では先頭の日を繰り返し処理します。処理済みの通知を送るバッチなら、同じ日の通知を何度も送ることになります。

開始日と終了日を含め、逆転した範囲は処理前に拒否する例です。DailyDateRunner.java の全文を掲載します。

package example.dates;

import java.time.LocalDate;
import java.util.function.Consumer;

public final class DailyDateRunner {
    private DailyDateRunner() {}

    public static void runInclusive(
            LocalDate from, LocalDate to, Consumer<LocalDate> dailyService) {
        if (from.isAfter(to)) {
            throw new IllegalArgumentException("開始日は終了日以前にしてください");
        }

        LocalDate current = from;
        while (true) {
            dailyService.accept(current);
            if (current.equals(to)) {
                return;
            }
            current = current.plusDays(1);
        }
    }
}

dailyService.accept(current) がその日付の処理です。終了日を処理したらその場で戻り、それ以外では current = current.plusDays(1) で次の日に進めます。開始日と終了日が同じ日なら1回で終わり、開始日が終了日より後なら1回も呼びません。

月末とうるう年はAPIに任せる

LocalDateはカレンダーを理解しているため、月末やうるう年を手計算する必要はありません。

System.out.println(LocalDate.of(2026, 1, 31).plusDays(1));
// 2026-02-01

System.out.println(LocalDate.of(2024, 2, 28).plusDays(1));
// 2024-02-29

ただしplusMonths(1)の結果は『同じ日番号が存在しなければ月末へ調整』されます。1か月後と翌月末は業務上同じとは限らないため、期待例を仕様へ書きます。

営業日計算はplusDaysだけでは足りない

土日、祝日、会社休日、銀行営業日を飛ばす締切計算は、単純なplusDaysでは表せません。休日カレンダーを参照する業務サービスとして分けます。

LocalDate nextBusinessDay(LocalDate date) {
    LocalDate candidate = date.plusDays(1);
    while (holidayCalendar.isHoliday(candidate)) {
        candidate = candidate.plusDays(1);
    }
    return candidate;
}

祝日マスタの更新時期、地域差、臨時休日などもあります。日付APIの正しさと休日データの正しさは別に監視します。

LocalDateと日時・タイムゾーンを混同しない

LocalDateには時刻とタイムゾーンがありません。利用者の現地日付、サーバー日付、UTC基準の締切を扱う場合、LocalDateだけでは情報が足りないことがあります。

LocalDate todayInTokyo = LocalDate.now(ZoneId.of("Asia/Tokyo"));
LocalDate todayInUtc = LocalDate.now(ZoneOffset.UTC);

テストでLocalDate.now()へ直接依存すると日付境界で不安定になります。現在日を使うServiceではClockを注入するなど、プロジェクトの方針に合わせて固定可能にします。

JUnitで月末と境界日を確認する

日付が変わったかは、plusDays(1) の結果だけでなく、日次処理へ渡された日付の一覧で確認します。2024年2月28日から3月1日なら、2月28日・2月29日・3月1日がこの順で1回ずつ渡ることを確かめます。

日付が進まないループをJUnitで再現する

ZIPには本文の DailyDateRunner.java、DailyDateRunnerTest.java、pom.xml、READMEを収録しています。JDK 17とMaven 3を用意し、展開先で mvn test を実行します。

サンプルZIPをダウンロード GitHubでコード全文と実行手順を見る

DailyDateRunnerTest.javaの全文を見る
package example.dates;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;

class DailyDateRunnerTest {
    @Test
    void runsSameDateOnce() {
        LocalDate date = LocalDate.of(2026, 1, 31);
        List<LocalDate> visited = new ArrayList<>();
        DailyDateRunner.runInclusive(date, date, visited::add);
        assertEquals(List.of(date), visited);
    }

    @Test
    void runsThroughLeapDayInOrder() {
        LocalDate from = LocalDate.of(2024, 2, 28);
        LocalDate leapDay = LocalDate.of(2024, 2, 29);
        LocalDate to = LocalDate.of(2024, 3, 1);
        List<LocalDate> visited = new ArrayList<>();

        DailyDateRunner.runInclusive(from, to, date -> {
            if (visited.contains(date)) {
                throw new AssertionError("同じ日を再処理しました " + date);
            }
            visited.add(date);
        });

        assertEquals(List.of(from, leapDay, to), visited);
    }

    @Test
    void rejectsReversedRangeBeforeProcessing() {
        LocalDate from = LocalDate.of(2026, 2, 1);
        LocalDate to = LocalDate.of(2026, 1, 31);
        List<LocalDate> visited = new ArrayList<>();
        assertThrows(IllegalArgumentException.class,
                () -> DailyDateRunner.runInclusive(from, to, visited::add));
        assertTrue(visited.isEmpty());
    }

    @Test
    void stopsAtMaximumDateWithoutAddingADay() {
        List<LocalDate> visited = new ArrayList<>();
        DailyDateRunner.runInclusive(LocalDate.MAX, LocalDate.MAX, visited::add);
        assertEquals(List.of(LocalDate.MAX), visited);
    }
}

修正版では4件が成功します。同じ開始日と終了日で1回、うるう日を含む3日で順序どおり、逆転範囲で0回、LocalDate.MAX の1日分で翌日を計算せずに終了することを確認します。

代入忘れを再現するには、DailyDateRunner.java の current = current.plusDays(1); を current.plusDays(1); に変更して、もう一度 mvn test を実行します。うるう日のテストは2月28日が2回目に渡された時点で「同じ日を再処理しました 2024-02-28」と失敗します。無限ループを待たずに原因を確認できます。

現場レビューでよくある指摘

日付処理のレビューでは、戻り値の代入だけでなく、暦日、営業日、締日、タイムゾーンのどれを計算しているかを確認します。

// レビューコメント例
plusDaysは元のLocalDateを変更しません。
戻り値をdateへ代入するか、nextDateとして利用してください。

// レビューコメント例
このwhileではcurrentが更新されないため処理が終わりません。
ループ更新式でcurrent = current.plusDays(1)としてください。

// レビューコメント例
仕様は翌営業日ですが、plusDays(1)では休日を考慮できません。
休日カレンダーを使う業務メソッドへ分けてください。

日付が1日ずれた障害は、データ修正や再請求につながります。手元の今日だけで確認せず、月末・年末・休日・タイムゾーンを含む具体例で説明します。

提出前のセルフチェック

レビュー前に確認すること

  • plusDaysの戻り値を使っているか
  • 元の日付を残す必要があるか
  • ループで日付が必ず進むか
  • 月末・年末・うるう年をテストしたか
  • 暦日と営業日を区別したか
  • 締日や月末調整の仕様を確認したか
  • タイムゾーンが必要な日時ではないか
  • 現在日に依存するテストを固定できるか

Javaの日付APIを基本から学ぶ参考書

LocalDate、LocalDateTime、ZoneIdの役割を体系的に整理しておくと、単純な日数加算と業務日付計算を混同しにくくなります。

書籍「スッキリわかるJava入門 実践編 第5版」の表紙
商品画像:Amazon.co.jp
PR Java基礎学習後の若手向け

スッキリわかるJava入門 実践編 第5版

中山 清喬 (著), 株式会社フレアリンク (監修)

基礎文法の次に必要な、現場寄りのJava知識を補う。

コレクション、ジェネリクス、ラムダ式、ストリームなど、業務コードで出会いやすい機能を入門編の次に学べます。

  • Java基礎の次に何を学ぶか迷っている
  • コレクションやStreamを整理したい

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

この記事とあわせて読みたい

関連記事LocalDate.parseでDateTimeParseExceptionになる原因|日付形式の指定方法 文字列をLocalDateへ変換する入口とDateTimeFormatterを解説します。 記事を読む 関連記事Spring Bootで400 Bad Requestになる原因|Controllerに値が入らない時の確認順 Controllerで日付形式が合わず値を受け取れない場合の確認順を整理します。 記事を読む 関連記事JavaのisEmptyとisBlankの違い|null・空文字・空白の判定方法 null・空文字・空白を区別し、入力条件に合う判定を確認します。 記事を読む

まとめ

LocalDateは不変オブジェクトなので、plusDaysは元の日付を書き換えず、新しいLocalDateを返します。日付を進めるなら戻り値を変数へ代入してください。

月末とうるう年はAPIへ任せられますが、営業日、締日、タイムゾーンは業務ルールです。ループの更新漏れと境界日をテストし、何の日付を計算しているか明確にします。

-実装・エラー解決
-,