Springを使用していると、特にサービスレイヤーを実装する際に、実装クラスが一つしかない場合でもインターフェースを定義すべきか、それとも省略して実装クラスを直接受け取るべきか悩むことがあります。
本記事では、さまざまな意見を調査した結果と、私なりの結論を整理いたします。
Spring DI(依存性注入)とは
まず、Spring DI(Dependency Injection)は依存オブジェクトを外部から注入することで、結合度を下げ、拡張性を高めます。
注入対象の型に応じて、大きく 2 つの方式に分類できます。
区分 | インターフェース注入 | 実装クラス注入 |
---|---|---|
結合度(Coupling) | 低い — 抽象レイヤに依存 | 高い — 具体クラスに直接依存 |
実装の置換・拡張 | インターフェースだけを変更すれば注入コードの変更は不要 | フィールド型や @Autowired の対象を修正する必要がある |
テスト時のモック容易性 | ラムダ/スタブで簡単に代替可能 | Mockito などの別フレームワークが必要 |
AOPプロキシ対応 | JDKダイナミックプロキシを使用 | CGLIBのバイトコード・サブクラス化を使用 |
コードで表すと以下のようになります。
インターフェース注入コード
public interface UserService {
User createUser();
}
public class UserServiceImpl implements UserService{
@Override
public User createUser() {
return new User("haroya");
}
}
public class UserController {
private final UserService userService;
// インターフェース型で注入を受け取る
@Autowired
public UserController(UserService userService) {
this.userService = userService;
}
実装クラス注入コード
public class UserService {
public User createUser() {
return new User("haroya");
}
}
public class UserController {
private final UserService userService;
// 実装クラスを直接注入する
@Autowired
public UserController(UserService userService) {
this.userService = userService;
}
インターフェースで注入する理由
1. 低結合と高い柔軟性
- 実装クラスが変わっても、注入先(コントローラーやサービス呼び出し側)はまったく気付きません。
public interface UserService {
User findById(Long id);
}
@RestController
public class UserController {
// インターフェース経由で注入 — 実装クラスが何であるかを知る必要はありません
private final UserService userService;
- 拡張に開かれ、変更に閉じている
OCP(Open‑Closed Principle、開放・閉鎖原則)
を遵守できます - インターフェースのみに依存するため、実装クラスの変更時にコード修正を最小限に抑えられます
// 通常の実装クラス
@Service
public class UserServiceImpl implements UserService { }
// キャッシュ機能が追加された別の実装クラス
@Service
@Primary // この実装クラスが優先して使用されます
public class CachedUserServiceImpl implements UserService { }
2. テストの容易性
- インターフェースに依存しているため、テスト環境では実際の実装クラスを使用する代わりに Mock オブジェクトを生成し、テスト時に注入できます
- Spring コンテキストを介さず、純粋な Java オブジェクトのみで単体テストを実行可能です
@ExtendWith(MockitoExtension.class)
public class UserControllerTest {
@Mock
private UserService userService; // インターフェースをモック化
@InjectMocks
private UserController userController;
3. Spring AOP とプロキシ機構の安定した動作
- CGLIB プロキシには致命的な制約があります
1. final キーワードの制約
@Service
public final class SecurityServiceImpl { // finalクラスはプロキシ化できません
@Transactional
public final void secureOperation() { // finalメソッドはオーバーライドできません
}
}
- クラスやメソッドに
final
キーワードを使用すると、継承とオーバーライドが不可能になり、CGLIB プロキシを生成できません
2. private コンストラクタの問題
@Service
public class SingletonServiceImpl {
private static SingletonServiceImpl instance;
private SingletonServiceImpl() { // private コンストラクタ
}
}
- CGLIB は対象クラスのコンストラクタにアクセスできて初めてプロキシを生成できます。
したがって、private
コンストラクタしか持たないクラスではプロキシを生成できません
3. コンストラクタ二重呼び出し問題
Spring 4.0 以前では、CGLIB プロキシを使用する際に対象クラスのコンストラクタが 2 回呼び出される問題がありました。
これは CGLIB が対象クラスを継承してプロキシを生成する過程で、Java の継承メカニズムにより親クラス(元のサービス)のコンストラクタが必ず呼び出される必要があったためです。
@Service
public class ExpensiveServiceImpl {
public ExpensiveServiceImpl() {
// データベース接続の初期化やキャッシュウォームアップなど、コストの高い処理がある場合はさらに問題になります
}
}
Spring 4.0 以降では、この問題を解決するために Objenesis ライブラリを利用し、コンストラクタ呼び出しを完全にバイパスする形で CGLIB プロキシを生成します。
The constructor of your proxied object will not be called twice, since the CGLIB proxy instance is created through Objenesis. However, if your JVM does not allow for constructor bypassing, you might see double invocations and corresponding debug log entries from Spring’s AOP support.
しかし、JVM やセキュリティ設定でコンストラクタのバイパスがブロックされると、依然としてコンストラクタが 2 回呼び出されたり、デバッグで重複メッセージが残る可能性があります。
2 つのプロキシ生成方式
- JDKダイナミックプロキシ: インターフェースベースで動作し、リフレクション API を用いて実行時にインターフェース実装を生成します。
- CGLIBプロキシ: クラスベースで動作し、バイトコード操作によって対象クラスのサブクラスを動的に生成します
Spring はインターフェースが存在する場合に JDK ダイナミックプロキシを、存在しない場合に CGLIB プロキシを使用します。
Spring Boot のデフォルト設定 spring.aop.proxy-target-class=true
は CGLIB を優先しますが、この設定を false
に変更すると、インターフェースが存在する場合にのみプロキシが生成されます。
実装クラスを直接注入する理由
@Service
public class UserService {}
1. 抽象化にもコストがかかる
- インターフェースを作成すれば抽象化というコストが発生します
- 実装クラスが 1 つしかない状況でも本当に必要かどうかを考えるべきです —
You Aren’t Gonna Need It
(YAGNI) 原則
2. Spring Boot のデフォルト設定
- Spring Boot はデフォルトで CGLIB プロキシ を使用するよう構成されています。
spring.aop.proxy-target-class=true
- そのため、最新の Spring Boot 環境ではインターフェースがなくても AOP 機能の大部分が問題なく動作します
3. コードの可読性と効率性
- IDE で実装クラスを直接参照すると、コードナビゲーションがより直感的になります。
- 実装クラスが一つだけであれば不要なファイルが減り、インターフェースと実装クラスを行き来しながらコードを読むオーバーヘッドも減少します
4. DDDにおけるドメインモジュールの純粋性
「ドメインモデルは技術的な抽象化ではなく、ビジネスの意味に集中すべきである」
-
ドメインモデルの純粋性維持
ドメインサービスをインターフェース経由で注入すると、ドメインが外部インフラストラクチャ(Spring DI)に依存してしまいます。具体クラスを直接注入すれば、ドメイン自体は DI フレームワークに依存しません。 -
不要な抽象化の排除
DDDではビジネスの意味に集中するべきですが、技術的抽象化(インターフェース)は実際のビジネス概念ではありません
Spring の公式推奨事項
As it is good practice to program to interfaces rather than classes, business classes normally implement one or more business interfaces.
Spring ブログでは「クラスよりインターフェースに対してプログラミングすることが望ましい慣例であるため、ビジネスクラスは通常一つ以上のビジネスインターフェースを実装します」と述べています。
つまり、インターフェースを注入することが推奨されています。
私なりの結論
SOLID の原則を守り、柔軟性や開発拡張性を確保することはすべて良いことだと考えます。
しかしサービスコードが本当に「依存しない」ままに保てるのか、
抽象化によって副作用を防げるのかについては、慎重に考える必要があります。
1. サービスコードはドメインの核心である
サービスレイヤーにはドメインの核心ロジックが含まれており、
このコードが変更されれば、関連する他のコードも自然にリファクタリングの対象となります。
つまり、サービスコード自体が強い依存性を持っているため、
無理にインターフェースで抽象化しても、実際には依存性は減りません。
2. 抽象化にはコストがかかり、不変であることを前提とする
抽象化レイヤーを設けることは、
「これは簡単には変わらない」という一種の明示(ドキュメント化)です。
しかし現実のビジネスは絶えず変化し、それに伴いサービスロジックも頻繁に変更されます。
こうした状況では、抽象化はむしろ保守コストと複雑さを増すだけで、実利は少ないと思います。
3. 実装クラスに依存すれば変更ポイントが明確になる
実装クラスに直接依存していれば、実際に変更が発生した際に
どこを修正すべきかを直感的に把握できます。
これはリファクタリング時により明確な境界を提供し、
不要な抽象化によるオーバーエンジニアリングを回避できると考えます。
参考資料