概要

interface と abstract class はどちらも「抽象化」の手段ですが、設計レビューで「なぜ interface ではなく abstract class にしたのか」と問われたとき、明確に答えられるでしょうか。Java 8 で default メソッドが追加されて以降、interface でも実装を持てるようになり、両者の境界はさらに曖昧になりました。この記事では、interface は「契約と能力の宣言」、abstract class は「共通の状態と振る舞いの共有」という原則に立ち返り、DAO パターンでの差し替え容易性、テスト用スタブの挿入、複数インターフェースの同時実装といった実務で直面する場面を通じて、使い分けの判断基準を整理します。Java 17 の record との組み合わせや、Java 21 の sealed interface による型安全な戻り値設計にも触れます。

使いどころ

データアクセス層を interface で抽象化し、本番用 MySQL 実装とテスト用 InMemory 実装を差し替え可能にする

帳票出力の共通処理(ヘッダー・フッター生成)を abstract class にまとめ、個別帳票でボディ部分だけをオーバーライドする

CSV エクスポートと PDF エクスポートの両方に対応するクラスに Exportable interface を複数実装させ、出力先を柔軟に切り替える

コード例

InterfaceVsAbstractDemo.java
import java.util.List;

public class InterfaceVsAbstractDemo {

    interface Printable {
        void print();
    }

    interface Saveable {
        void save(String path);

        // default メソッド: フォールバック実装を提供
        default void saveToTemp() {
            save("/tmp/default.txt");
        }
    }

    abstract static class Animal {
        private final String name;

        public Animal(String name) {
            this.name = name;
        }

        public String getName() {
            return name;
        }

        public abstract String sound();

        // 共通ロジック: サブクラスはsound()だけ実装すればよい
        public void introduce() {
            System.out.println(
                "私は " + name + " です。" + sound() + " と鳴きます。");
        }
    }

    static class Dog extends Animal implements Printable, Saveable {
        public Dog(String name) {
            super(name);
        }

        @Override
        public String sound() {
            return "ワン";
        }

        @Override
        public void print() {
            System.out.println("Dog: " + getName());
        }

        @Override
        public void save(String path) {
            System.out.println("保存先: " + path);
        }
    }

    record UserResult(int id, String name, String source) {}

    interface UserDao {
        UserResult findById(int id);
        void save(String user);
    }

    static class MySqlUserDao implements UserDao {
        @Override
        public UserResult findById(int id) {
            return new UserResult(id, "User-" + id, "MySQL");
        }

        @Override
        public void save(String user) {
            System.out.println("MySQL に保存: " + user);
        }
    }

    // テスト用スタブ: interface のおかげで差し替えが容易
    static class InMemoryUserDao implements UserDao {
        @Override
        public UserResult findById(int id) {
            return new UserResult(id, "User-" + id, "Memory");
        }

        @Override
        public void save(String user) {
            System.out.println("メモリに保存: " + user);
        }
    }

    public static void main(String[] args) {
        // abstract class: 共通ロジックの活用
        var dog = new Dog("ポチ");
        dog.introduce();       // abstract class の共通メソッド
        dog.print();           // Printable interface
        dog.saveToTemp();      // Saveable default メソッド

        // interface: DAO の差し替え
        var daos = List.of(new MySqlUserDao(), new InMemoryUserDao());
        for (var dao : daos) {
            var result = dao.findById(1);
            System.out.println(result);
        }
    }
}

Java 8 / 17 / 21 の完全なサンプルコードは GitHub リポジトリ で確認できます。

Version Coverage

record で DAO の戻り値を簡潔に定義できる。var による型推論と組み合わせると、匿名クラスでの interface 実装も見通しが良くなる。

Java 17
// Java 17: record で戻り値を簡潔に定義
record UserResult(int id, String name, String source) {}
interface UserDao {
    UserResult findById(int id);
    void save(String user);
}
// 匿名クラスでも var + record で簡潔に
var dao = new UserDao() {
    public UserResult findById(int id) {
        return new UserResult(id, "User-" + id, "MySQL");
    }
    public void save(String user) { /* ... */ }
};

Library Comparison

標準 API(interface + abstract class)プロジェクト固有の抽象化レイヤーを自前で組み立てるとき。依存なしでテスト差し替えやモック挿入が可能。設計判断はチームに委ねられるため、interface と abstract class の使い分けルールを合意しておく必要がある。
Spring Framework(DI コンテナ)interface の実装切り替えを設定ファイルやアノテーションで管理したいとき。大規模プロジェクトでの依存解決に向く。フレームワークへの依存が生まれる。小規模なユーティリティや Pure Java の学習目的には過剰になりやすい。
Lombok(@Value / @Builder)ボイラープレートの削減が主目的のとき。getter / equals / hashCode を自動生成してくれる。IDE やビルドツールとの相性問題が起きることがある。Java 17 以降は record で代替できる場面が増えている。

注意点

interface の default メソッドに業務ロジックを詰め込みすぎると、実装クラス側でオーバーライドされていることに気づきにくくなる。default はフォールバック用途に留めるのが安全

abstract class は単一継承のため、後から別の abstract class を追加できない。将来の拡張を考えると、共通状態が不要なら interface を優先するほうが柔軟性が残る

Java 8 以降の interface は static メソッドも持てるが、継承されない点に注意。ユーティリティ的なメソッドを interface に置く場合は、呼び出し側で インターフェース名.メソッド名() と書く必要がある

DAO インターフェースの戻り値を String や Map にすると型安全性が失われる。Java 17 なら record、Java 21 なら sealed interface と組み合わせて戻り値を明示するとコードレビューでの見通しが良くなる

FAQ

default メソッドが衝突した場合はどうなりますか。

2つの interface が同じシグネチャの default メソッドを持つ場合、実装クラスで明示的にオーバーライドしないとコンパイルエラーになります。InterfaceA.super.method() の形で特定の実装を呼び出せます。

abstract class にコンストラクタは必要ですか。

共通フィールドを初期化する場合は必要です。サブクラスから super() で呼び出す設計が基本です。状態を持たないなら interface のほうが適切な場合が多いです。

テスト用のモック実装は interface と abstract class のどちらが作りやすいですか。

interface のほうがモック作成は容易です。Mockito などのフレームワークも interface ベースが基本で、abstract class のモックには追加設定が必要になることがあります。

関連書籍

この記事のテーマをさらに深く学びたい方へ。

※ Amazon アソシエイトリンクを含みます