1. 導入:なぜXxxServiceImplという命名が長らく定着していたのか
Spring Bootのプロジェクトを見ていると、ProductServiceというインターフェースと、それを実装するProductServiceImplというクラスをセットで作る構成に頻繁に出会います。この構成は長年「Springらしい書き方」として定着しており、疑問を持たずに踏襲されていることも少なくありません。
// よく見る構成
public interface ProductService {
ProductResult getProduct(Long id);
}
public class ProductServiceImpl implements ProductService {
@Override
public ProductResult getProduct(Long id) {
// ...
}
}
しかしこの構成は、元々技術的な制約を回避するための対処として広まったものです。その制約自体がすでに過去のものになっているにもかかわらず、パターンだけが慣習として残り続けているケースが多く見られます。本記事では、この構成が必要とされていた背景と、現在の判断基準を整理します。
2. なぜインターフェースが必要とされていたのか(JDK Dynamic Proxyの制約)
Spring AOP(@Transactional、@Serviceのようなアノテーションでの機能拡張を含む)は、対象のクラスに対してプロキシを生成することで実現されています。このプロキシ生成には歴史的に2つの方式があります。
| 方式 | 生成対象 | 特徴 |
|---|---|---|
| JDK Dynamic Proxy | インターフェースの実装クラス | 対象クラスが実装しているインターフェースをもとに、実行時にプロキシを動的生成する |
| CGLIB | 具象クラス(インターフェース不問) | 対象クラスを継承したサブクラスを実行時に生成する |
JDK Dynamic Proxyは、その仕組み上インターフェースの実装クラスにしか適用できません。プロキシがインターフェースのメソッドをオーバーライドする形で処理を差し込むため、対象クラスがインターフェースを実装していなければ、そもそもプロキシを作りようがないためです。
Spring AOPの初期のデフォルトはこのJDK Dynamic Proxyでした。つまり、@TransactionalのようなAOPによる機能拡張をProductServiceに効かせたい場合、ProductServiceがインターフェースであることが事実上の必須条件だったのです。
[JDK Dynamic Proxy時代]
ProductService (interface)
↑ implements
ProductServiceImpl
↑ プロキシ生成にはインターフェースが必要
ProductServiceProxy(動的生成)
XxxServiceImplという命名パターンは、この制約の下で「インターフェースと実装クラスを分離する」ことが半ば強制されていた結果、定着した慣習でした。
3. CGLIBプロキシがデフォルトになったことでの変化
現在のSpring Boot(本記事の対象であるSpring Boot 4.1を含む長年のバージョン)では、spring.aop.proxy-target-class=trueが事実上のデフォルトとなっており、CGLIBによるサブクラス化プロキシが標準の動作になっています。
CGLIBはバイトコード生成ライブラリで、対象クラスを継承したサブクラスを実行時に作り出すことでプロキシを実現します。この方式では、対象クラスがインターフェースを実装しているかどうかは一切関係ありません。具象クラスであっても、そのままプロキシの対象にできます。
[CGLIBプロキシ時代]
ProductService(具象クラス。インターフェース不要)
↑ 継承してサブクラスを生成
ProductServiceProxy(動的生成)
つまり、「@Transactionalを効かせるためにインターフェースが必要」という制約は、現在のSpring Bootでは技術的に存在しません。ProductServiceを具象クラスのまま@Serviceとして登録しても、@Transactionalは問題なく機能します。
// ✅ インターフェースなしでも @Transactional は正常に効く
@Service
@Transactional(readOnly = true)
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public ProductResult getProduct(Long id) {
// ...
}
}
4. それでも「ServiceImplパターンを採用しない」という設計判断の理由
技術的制約が外れた以上、インターフェースを作るかどうかは純粋に設計上の判断になります。ここでの原則は、「インターフェースを定義するのは、複数実装が存在する理由がある場合のみ」というシンプルなものです。
「将来のために念のためインターフェースを切っておく」という予防的な設計は、一見安全に見えて次のような無駄を生みます。
- YAGNI原則との衝突 ― "You Aren't Gonna Need It"、実際に必要になっていない抽象化を先回りして用意することは、多くの場合ただのコストになります。複数実装が本当に必要になった時点でインターフェースを抽出すれば十分であり、Java/IDEのリファクタリング機能を使えばこの抽出コストは低いものです
-
IDEでのナビゲーションの摩擦 ―
ProductServiceの呼び出し元から「実装へジャンプ」しようとすると、単一実装しかない状況でも「インターフェース→実装クラス」という1ステップが常に挟まります。実装が1つしかないなら、このステップは純粋なオーバーヘッドです - 見せかけの抽象化 ― インターフェースがあることで「差し替え可能な設計である」という印象を与えますが、実装が1つしかなければその印象は実態を伴いません。むしろ「本当に複数実装があるのはどのクラスか」を判別しづらくする副作用すらあります
5. インターフェースを定義してよい場合
原則を反転させると、次のような複数実装が存在する具体的な理由があるときは、インターフェースを定義する判断が正当化されます。
- 環境ごとに実装を切り替えたい(例:決済処理で本番用のPaymentGatewayと、開発環境用のスタブ実装を切り替える)
- 同じ振る舞いの契約に対して、複数のアルゴリズム実装を用意したい(Strategy パターンに相当するケース)
一方で、「テストでモックに差し替えたいから」という理由だけでインターフェースを残す主張については、注意が必要です。Mockitoの@Mockや@MockitoBeanは、インターフェースがなくても具象クラスに対してそのままモック化が可能です。
@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
@InjectMocks ProductService productService; // 具象クラスのまま
@Mock ProductRepository productRepository; // インターフェースだが、これは元々Spring Data JPAの都合
@Test
void getProduct_whenNotFound_shouldThrow() {
given(productRepository.findById(99L)).willReturn(Optional.empty());
assertThrows(ProductNotFoundException.class,
() -> productService.getProduct(99L));
}
}
つまり「テスト容易性のためにインターフェースが必要」という主張は、CGLIBプロキシの話とは別に、Mockitoの技術的制約という観点からも、現在では成立しません。テストのためにインターフェースを残す必要はないのです。
6. まとめ
ServiceImplパターンの歴史は、「歴史的な技術制約が理由だった設計パターンが、制約が外れた後も惰性で残り続ける」ことの典型例です。
- JDK Dynamic Proxy時代は、AOP(
@Transactional等)を効かせるためにインターフェースが事実上必須だった - CGLIBプロキシがデフォルトになった現在、その制約は存在しない
- インターフェースを定義する判断基準は「複数実装が存在する理由があるか」に一本化してよい
- テスト容易性を理由にインターフェースを残す主張も、Mockitoが具象クラスを直接モック化できる以上、根拠として成立しない
技術的な制約が変わったときに、それに紐づいていた設計パターンまで一緒に見直す、という姿勢が、慣習に流されないアーキテクチャ判断につながります。