日付重複判定ユーティリティは何のために必要か
業務システムでは、期間がかぶってはいけない場面がたくさんあります。
割引キャンペーン期間が重複してはいけない。
同じ部屋の予約が同じ時間帯に二重に入ってはいけない。
社員の所属期間や契約期間が矛盾してはいけない。
こういうときに必要になるのが「日付重複判定」のユーティリティです。
期間同士が重なっているかどうかを、毎回 if 文で書くのではなく、
小さなメソッドにまとめておくことで、バグを減らしつつコードを読みやすくできます。
まずは「期間」をどう表現するかを決める
LocalDate で期間を表す基本形
日付の期間は、ふつう「開始日」と「終了日」のペアで表します。
まずは LocalDate を使ったシンプルな期間クラスを作ってみます。
import java.time.LocalDate;
public class DateRange {
private final LocalDate startInclusive;
private final LocalDate endInclusive;
public DateRange(LocalDate startInclusive, LocalDate endInclusive) {
if (endInclusive.isBefore(startInclusive)) {
throw new IllegalArgumentException("終了日は開始日以降である必要があります");
}
this.startInclusive = startInclusive;
this.endInclusive = endInclusive;
}
public LocalDate getStartInclusive() {
return startInclusive;
}
public LocalDate getEndInclusive() {
return endInclusive;
}
}
Javaここで重要なのは、コンストラクタで「終了日が開始日より前なら例外」にしていることです。
このチェックをサボると、「おかしな期間」がシステムの中に紛れ込み、
重複判定の前にロジックが破綻してしまいます。
二つの期間が重なっているかどうかの考え方
直感的なイメージから式に落とす
二つの期間 A と B が重なっているかどうかを、図でイメージしてみます。
A: [Astart ───── Aend]
B: [Bstart ───── Bend]
このように、どこか一部でも重なっていれば「重複あり」です。
逆に、次のどちらかなら「重複なし」です。
A が完全に B より前にある
A が完全に B より後ろにある
これを式にすると、こうなります。
重複していない条件
Aend < Bstart または Bend < Astart
つまり、重複している条件はその逆で、
Aend >= Bstart かつ Bend >= Astart
となります。
これを LocalDate と DateRange に落とし込むと、次のようなユーティリティになります。
public class DateRangeOverlap {
public static boolean overlaps(DateRange a, DateRange b) {
LocalDate aStart = a.getStartInclusive();
LocalDate aEnd = a.getEndInclusive();
LocalDate bStart = b.getStartInclusive();
LocalDate bEnd = b.getEndInclusive();
boolean aEndsBeforeBStarts = aEnd.isBefore(bStart);
boolean bEndsBeforeAStarts = bEnd.isBefore(aStart);
return !(aEndsBeforeBStarts || bEndsBeforeAStarts);
}
}
Javaここで深掘りしたいのは、「重複していない条件を先に考えて、その否定を取る」という発想です。
重複しているパターンを全部列挙しようとすると混乱しますが、
「完全に前」「完全に後ろ」の二つだけを考えればよいので、頭がスッキリします。
境界を含むかどうかの扱い(同じ日を共有する場合)
終了日と開始日が同じときは重複とみなすか
例えば、次の二つの期間を考えます。
A: 2025-03-01 〜 2025-03-10
B: 2025-03-10 〜 2025-03-20
このとき、「3月10日を共有しているので重複」とみなすか、
「A は 10 日まで、B は 10 日からで“隙間なしだが重複なし”」とみなすかは、業務ルール次第です。
先ほどの DateRange は「開始も終了も含む(閉区間)」として設計しているので、
上の A と B は「重複あり」と判定されます。
もし「終了日と開始日が同じだけなら重複なし」としたい場合は、
半開区間(開始含む・終了含まない)で期間を表現する方が自然です。
public class DateRangeHalfOpen {
private final LocalDate startInclusive;
private final LocalDate endExclusive;
public DateRangeHalfOpen(LocalDate startInclusive, LocalDate endExclusive) {
if (!endExclusive.isAfter(startInclusive)) {
throw new IllegalArgumentException("終了日は開始日より後である必要があります");
}
this.startInclusive = startInclusive;
this.endExclusive = endExclusive;
}
public LocalDate getStartInclusive() {
return startInclusive;
}
public LocalDate getEndExclusive() {
return endExclusive;
}
}
Javaこの場合の重複判定は、次のように書けます。
public class DateRangeHalfOpenOverlap {
public static boolean overlaps(DateRangeHalfOpen a, DateRangeHalfOpen b) {
LocalDate aStart = a.getStartInclusive();
LocalDate aEnd = a.getEndExclusive();
LocalDate bStart = b.getStartInclusive();
LocalDate bEnd = b.getEndExclusive();
boolean aEndsOnOrBeforeBStarts = !aEnd.isAfter(bStart); // aEnd <= bStart
boolean bEndsOnOrBeforeAStarts = !bEnd.isAfter(aStart); // bEnd <= aStart
return !(aEndsOnOrBeforeBStarts || bEndsOnOrBeforeAStarts);
}
}
Javaここでのポイントは、「期間の意味(閉区間か半開区間か)をクラスで固定する」ことです。
業務としてどちらの解釈にするかをチームで決め、そのルールをユーティリティに閉じ込めておくと、
境界のバグを大きく減らせます。
実例で重複判定を確認してみる
いくつかのパターンをコードで試す
先ほどの閉区間版 DateRange と overlaps を使って、いくつかのパターンを試してみます。
public class OverlapExample {
public static void main(String[] args) {
DateRange a = new DateRange(
LocalDate.of(2025, 3, 1),
LocalDate.of(2025, 3, 10)
);
DateRange b = new DateRange(
LocalDate.of(2025, 3, 5),
LocalDate.of(2025, 3, 15)
);
DateRange c = new DateRange(
LocalDate.of(2025, 3, 11),
LocalDate.of(2025, 3, 20)
);
DateRange d = new DateRange(
LocalDate.of(2025, 3, 10),
LocalDate.of(2025, 3, 10)
);
System.out.println(DateRangeOverlap.overlaps(a, b)); // true(がっつり重なっている)
System.out.println(DateRangeOverlap.overlaps(a, c)); // false(完全に後ろ)
System.out.println(DateRangeOverlap.overlaps(a, d)); // true(境界の1日を共有)
}
}
Javaこうやって具体的な日付で試してみると、
自分が想定している「重複」の定義と、実装が一致しているかを確認しやすくなります。
複数の期間同士の重複チェック
新しい期間が既存のどれかと重なっていないか
実務では、「新しく登録しようとしている期間が、既存の期間と重なっていないか」をチェックしたいことが多いです。
例えば、会議室予約やキャンペーン設定などです。
import java.util.List;
public class DateRangeValidator {
public static boolean overlapsAny(DateRange target, List<DateRange> existing) {
for (DateRange r : existing) {
if (DateRangeOverlap.overlaps(target, r)) {
return true;
}
}
return false;
}
}
Java使い方のイメージはこうです。
List<DateRange> existing = List.of(
new DateRange(LocalDate.of(2025, 3, 1), LocalDate.of(2025, 3, 10)),
new DateRange(LocalDate.of(2025, 3, 20), LocalDate.of(2025, 3, 25))
);
DateRange newRange = new DateRange(LocalDate.of(2025, 3, 8), LocalDate.of(2025, 3, 12));
if (DateRangeValidator.overlapsAny(newRange, existing)) {
System.out.println("既存の期間と重複しています");
} else {
System.out.println("重複なしで登録できます");
}
Javaここで大事なのは、「重複判定のロジックを一か所に集約している」ことです。
画面やサービス層のあちこちで期間比較の if 文を書き散らすのではなく、
必ず overlaps / overlapsAny を通すようにしておくと、
仕様変更やバグ修正がとても楽になります。
LocalDateTime / Instant の重複判定とタイムゾーン
日時の重複判定も考え方は同じ
LocalDateTime や Instant でも、基本の考え方は同じです。
開始と終了を持つ Range クラスを作り、
「完全に前」「完全に後ろ」の否定で重複を定義します。
import java.time.LocalDateTime;
public class DateTimeRange {
private final LocalDateTime startInclusive;
private final LocalDateTime endExclusive; // 半開区間にしておくと扱いやすい
public DateTimeRange(LocalDateTime startInclusive, LocalDateTime endExclusive) {
if (!endExclusive.isAfter(startInclusive)) {
throw new IllegalArgumentException("終了日時は開始日時より後である必要があります");
}
this.startInclusive = startInclusive;
this.endExclusive = endExclusive;
}
public boolean overlaps(DateTimeRange other) {
boolean thisEndsOnOrBeforeOtherStarts = !this.endExclusive.isAfter(other.startInclusive);
boolean otherEndsOnOrBeforeThisStarts = !other.endExclusive.isAfter(this.startInclusive);
return !(thisEndsOnOrBeforeOtherStarts || otherEndsOnOrBeforeThisStarts);
}
}
Javaただし、ここで必ず意識してほしいのが「タイムゾーン」です。
LocalDateTime はタイムゾーンを持たないので、
「同じタイムゾーンで解釈される前提」で使う必要があります。
タイムゾーンをまたいで“瞬間”として重複判定したい場合は、
ZonedDateTime や Instant を使い、
すべて Instant に変換してから比較する方が安全です。
セキュリティ・運用の観点から見た日付重複判定
重複バグはそのまま業務トラブルになる
日付重複判定を間違えると、直接ビジネスに影響します。
会議室やリソースの二重予約。
キャンペーンが意図せず重なり、割引が二重に適用される。
契約期間が重なってしまい、どの契約を適用すべきか分からなくなる。
これらは、クレームや損失、契約トラブルにつながります。
だからこそ、「期間の表現」「重複の定義」「境界の扱い」を、
ユーティリティとしてきちんと設計しておくことが重要です。
期間の逆転や異常値を必ず弾く
開始 > 終了 の期間をそのまま受け入れてしまうと、
重複判定以前にデータが破綻します。
コンストラクタやファクトリメソッドで
「終了が開始より前なら例外」
というチェックを必ず入れておくことで、
設定ミスやバグを早期に検出できます。
これは、セキュリティというより「堅牢性」の話ですが、
結果として不正なデータや攻撃的な入力に対する防御線にもなります。
まとめ:日付重複判定ユーティリティで身につけてほしい感覚
日付重複判定は、「二つの期間がどこか一部でも重なっているか?」を調べるだけの処理です。
しかし、その裏には次のような大事なポイントが隠れています。
期間は「開始」と「終了」のペアとしてクラスで表現する。
終了が開始より前の期間はコンストラクタで弾く。
重複していない条件(完全に前・完全に後ろ)を先に考え、その否定で重複を定義する。
閉区間か半開区間かをプロジェクトとして決め、そのルールをクラスに閉じ込める。
LocalDate / LocalDateTime / Instant を、何を比較したいかに応じて使い分ける。
もしあなたのコードの中に、
開始と終了を直接 compareTo でゴチャゴチャ比較している if 文があれば、
それを一度「期間クラス+重複判定ユーティリティ」に置き換えられないか眺めてみてください。
それだけで、コードの意図がはっきりし、
二重予約や期間の矛盾といった“業務トラブルの種”を、かなりの確率で潰せるようになります。
