導入:なぜSealed Interfacesが重要なのか
Javaの継承は強力な反面、無制限に継承を許すと「誰がどこで実装しているか」を追跡することが困難になります。特にドメインモデルの定義において、特定の型のみを許容したいケースは多々あります。Sealed Interfacesは、継承可能なクラスやインターフェースを開発者が明示的に制限する機能です。これにより、コンパイラが「全てのケースが網羅されているか」をチェック可能になり、バグの温床となりやすいif-elseの連鎖から脱却し、堅牢な制御フローを実現できます。
基礎知識:Sealed Interfacesの仕組み
Sealed Interfaces(シールインターフェース)は、許可されたクラスやインターフェースのみがそのインターフェースを実装または継承できるようにする機能です。キーワードは以下の3つです。
sealed:インターフェース(またはクラス)に付与し、制限を宣言します。
permits:許可するサブタイプを列挙します。
non-sealed / final:サブクラス側で継承関係を終了させる、あるいは制限を解除するために指定します。
これらを「switch expressions(switch式)」と組み合わせることで、Javaコンパイラは「全てのサブタイプが処理されているか」を静的に検証できます。
実装/解決策:網羅的な制御フローの構築
現場では、状態遷移やドメインイベントの処理にこの組み合わせを活用します。if-elseで型チェックを行うと、新しい型を追加した際にコンパイルエラーにならず、実行時までバグが潜むリスクがあります。一方、Sealed Interfacesとswitch式を使えば、未実装の型がある場合にコンパイルエラーとなるため、保守性が飛躍的に向上します。
サンプルプログラム
以下のコードは、決済状態を管理するドメインモデルの例です。
// 許可されたサブクラスのみを定義したSealed Interface
public sealed interface PaymentStatus permits Success, Failed, Pending {}
// 各状態の実装(finalで継承を止めるのが推奨)
record Success(String transactionId) implements PaymentStatus {}
record Failed(String reason) implements PaymentStatus {}
record Pending() implements PaymentStatus {}
public class PaymentProcessor {
public String process(PaymentStatus status) {
// switch式による網羅的な処理
// もし新しい状態を追加した場合、ここでコンパイルエラーになるため修正漏れを防げる
return switch (status) {
case Success s -> {
System.out.println(“決済成功 ID: ” + s.transactionId());
yield “完了”;
}
case Failed f -> {
System.out.println(“決済失敗 理由: ” + f.reason());
yield “エラー”;
}
case Pending p -> {
System.out.println(“処理中…”);
yield “保留”;
}
};
}
}
応用・注意点:現場での活用と落とし穴
1. 網羅的チェックの恩恵
switch式で網羅性(Exhaustiveness)が保証されるため、default句を安易に書くのは避けましょう。defaultを書くと、将来新しい型が増えたときにコンパイラが警告を出せなくなるため、型安全性が損なわれます。
2. non-sealedの使いどころ
もし、ライブラリのユーザーが自由に拡張できるようにしたい場合は、サブクラスに non-sealed を付与することで、Sealedの制限を意図的に解除できます。しかし、基本的にはエンティティの定義を固定するために使用するのがベストプラクティスです。
3. 階層構造の深さ
Sealed Interfaceは多層に定義可能ですが、複雑にすると可読性が落ちます。ビジネスロジックの境界線(Bounded Context)ごとにシンプルに定義するのが、シニアエンジニアとして推奨する設計方針です。

コメント