1. 概要(Overview)
Extract Interface は、複数のクラスに見られる 共通のふるまい(メソッド群) を
インターフェースとして切り出し、クライアントをそのインターフェースに依存させるリファクタリングです。
継承(スーパークラス抽出)と違い、状態(フィールド)の共有は行わず、契約(API)だけ を共通化します。
実装を差し替えやすくなり、モジュール間の結合を弱められます。
目的:
- 依存関係の逆転(DIP)/抽象化による疎結合化
- テスト容易性の向上(モック・スタブ差し替えが容易)
- 実装の入れ替え・並存を可能にする(戦略の切替)
2. 適用シーン(When to Use)
- 複数クラスが 同じメソッドセット を(名前・概念的に)持っている
- クライアントが 具体クラス に依存しており、差し替えが難しい
- 将来、別実装(DB→API、同期→非同期等)を導入する見込みがある
- 単体テストで 代替実装に置き換えたい(モックしたい)
よくある匂い:
- Duplicate Code(重複)(ふるまいの重複だが継承は不自然)
- Inappropriate Intimacy(不適切な親密さ)(具体に強く依存)
- Shotgun Surgery(差し替え時の影響範囲が広い)
3. 手順(Mechanics / Steps)
- 共有したいメソッド群を抽出(引数・戻り値を可能な限り共通化)
- インターフェース を定義
- 対象クラスにそのインターフェースを implements(Kotlin:
:)させる - クライアントの依存先を 具体型 → インターフェース型 に置換
- コンパイル&テスト(モック置換・差し替えも確認)
4. Kotlin 例(Before → After)
Before:クライアントが具体クラスに依存
class LocalUserRepository {
fun getUser(id: String): User? { /* ... */ return null }
fun save(user: User) { /* ... */ }
}
class RemoteUserRepository {
fun getUser(id: String): User? { /* ... */ return null }
fun save(user: User) { /* ... */ }
}
class UserService(
// 具体クラスに依存してしまっている
private val repo: LocalUserRepository
) {
fun load(id: String): User? = repo.getUser(id)
}
-
LocalとRemoteで 同名ふるまい を持っているのに、共通の契約がない -
UserServiceはLocalUserRepositoryに固定依存で差し替え不能
After:インターフェースを抽出して依存逆転
interface UserRepository {
fun getUser(id: String): User?
fun save(user: User)
}
class LocalUserRepository : UserRepository {
override fun getUser(id: String): User? { /* ... */ return null }
override fun save(user: User) { /* ... */ }
}
class RemoteUserRepository : UserRepository {
override fun getUser(id: String): User? { /* ... */ return null }
override fun save(user: User) { /* ... */ }
}
class UserService(
// 抽象に依存(DIP)
private val repo: UserRepository
) {
fun load(id: String): User? = repo.getUser(id)
}
-
UserRepositoryを抽出し、Local/Remoteが実装 -
UserServiceは抽象に依存するので、実装の差し替え・モック化が容易
5. 効果(Benefits)
- 疎結合化:クライアントが具象から独立
- テスト容易性:インターフェースをモック・フェイクで差し替え
- 拡張性:新しい実装(例えばキャッシュつき、レート制限つき等)を無停止で追加可能
- 並存運用:段階移行(Local→Remote)や A/B 切替がやりやすい
6. 注意点(Pitfalls)
- インターフェースの 過剰細分化(I/F が増えすぎると可読性低下)
- 契約の早すぎる固定化:検証前に固めると将来の変更に硬直化
- 状態を共有したい場合は Extract Superclass のほうが自然(I/F は振るまい契約のみ)
- デフォルト実装を多用するなら、Strategy/委譲 も検討(I/F+実装注入)
7. 実務Tips
- 最小限のメソッド から始める(YAGNI)
- I/F 名は 役割名 に(
Repository,Encoder,Clockなど) - 戻り値・例外契約 を明確にし、呼び出し側の責務(リトライ/リカバリ)を設計
- Kotlin では I/F に デフォルト実装 を置けるが、複雑ロジックは避ける(肥大化の元)
- DI コンテナ(Koin/Hilt)と相性良し:抽象にバインド して注入
8. もう一例:決済戦略の切替(Strategy と好相性)
interface PaymentGateway {
fun pay(amount: Money): PaymentResult
}
class StripeGateway : PaymentGateway {
override fun pay(amount: Money): PaymentResult { /* ... */ }
}
class PayPalGateway : PaymentGateway {
override fun pay(amount: Money): PaymentResult { /* ... */ }
}
class CheckoutService(private val gateway: PaymentGateway) {
fun checkout(amount: Money): PaymentResult = gateway.pay(amount)
}
- インターフェース抽出で ランタイム差し替え(戦略切替)が容易
- テストでは
FakeGatewayを注入して 決済外部依存を排除
まとめ
-
Extract Interface は、共通ふるまいを契約として切り出し、
具体への依存を抽象へ置き換える リファクタリング - 判断基準:共通メソッドがあり、差し替え・テスト・拡張の必要があるか?
- 基本思想:DIP/ISP を満たし、疎結合・可テスト・拡張容易な設計へ