Flutter クリーンアーキテクチャ完全ガイド② - クリーンアーキテクチャの目的&前提知識
Flutter クリーンアーキテクチャ完全ガイド② - クリーンアーキテクチャの目的&前提知識
あなたはなぜクリーンアーキテクチャを導入したいのでしょうか?
この記事を読んでくれている方は、きっとクリーンアーキテクチャを自身のflutterプロジェクトに取り入れたいと思っているのだと思います。ありがとうございます。
ところで、クリーンアーキテクチャに限らずアーキテクチャなんて使わなくてもプログラムは動くのに、なぜアーキテクチャに沿って作ろうとしているのでしょうか?(もちろん意地悪で聞いているわけではありません!)
アーキテクチャを導入する目的からスタートしないと、「なんでこんなことをするんだ」、「無駄じゃないか」そんな風に思って理解に苦しんでしまう場面がくると思います。私自身、「とりあえず、クリーンアーキテクチャがいいらしいぞ」というだけのモチベーションで学習・導入をして痛い目を見ました。
そうならないように、一緒に「なぜこのアーキテクチャが必要なのか」を理解していきましょう。
目的あってこそのアーキテクチャ
アーキテクチャとは、要するに「コードの整理整頓の方法」です。そして、それぞれのアーキテクチャには明確な目的があります。
例えば:
- MVC:UIとビジネスロジックを分離したい
- MVVM:データバインディングでUI更新を自動化したい
- クリーンアーキテクチャ:テストしやすく、変更に強いコードを書きたい
目的を理解せずにアーキテクチャを導入すると、ただディレクトリ構成を真似しただけの「なんちゃってアーキテクチャ」になってしまいます。
アーキテクチャに正解はない
ここで重要なことをお伝えします。アーキテクチャに絶対的な正解はありません。
アーキテクチャは方法論の一つであり、他のアーキテクチャの思想と矛盾することもあります。また、プロジェクトの規模や要件によっては、導入しない方がいいこともあるのです。
なぜなら、アーキテクチャの導入には必ずコストが伴うからです:
学習コスト
- チーム全員がアーキテクチャを理解する必要がある
- 間違った理解で導入すると、かえって複雑になる
メンテナンスコスト
- ファイル数が増える
- レイヤー間の調整が必要
- シンプルな変更でも複数ファイルの修正が必要
例えば、1人で1週間で作る簡単なプロトタイプアプリに、クリーンアーキテクチャを導入するのは明らかにオーバーエンジニアリングです。StatefulWidgetとProviderだけで十分かもしれません。
大切なのは、プロジェクトの目的と状況に応じて、適切なアーキテクチャを選択することです。
クリーンアーキテクチャは何を達成したのか
結論:クリーンアーキテクチャは次の3つを実現します。
- テストしやすいコード
- 変更に強いコード
- チーム開発に強いコード
この中で、私が強調したいのがテストしやすいコードを実現してくれるという点です。
確かに、クリーンアーキテクチャは変更に強いコードの実現につながります。クリーンアーキテクチャでは責務の分離が十分になされているからです。ただ、最低限のアーキテクチャに従っていれば、変更すべき場所はある程度予測がつきます。
また、チーム開発に強いコードについても同様です。責務が明確に分離されていることで、メンバー同士の作業が独立し、並行開発が可能になります。「ユーザー登録のUI担当」「API通信担当」「ビジネスロジック担当」のように役割分担ができ、お互いの作業を待つ必要がありません。さらに、コードレビュー時も「この層では何をチェックすべきか」が明確になり、効率的なレビューが可能になります。
テストしやすさの劇的な改善
導入前はテストを書こうとすると必ずこんな壁にぶつかりました:
- テストコードどんなテストを書けばいいかわからない
- 実際は動いているのにテストコードだけ通らない。
- テストをかけないケースがある
テストしようとすると...
// 導入前
test('ユーザー情報取得のテスト', () {
// ❌ テストコードどんなテストを書けばいいかわからない(テスト対象のクラスの責務が曖昧)
// ❌ 実際は動いているのになぜかテストコードだけ通らない。
// ❌ テストをかけないケースがある
// 結果:テストできない、または不安定
});
クリーンアーキテクチャ導入後は、必要なテストを正しくできるようになりました。
// 導入後
test('ユーザー情報取得のテスト', () {
// ✅ 責務の範囲でテスト
// ✅ モックを使って外部依存なし
// ✅ テストを前提とした実装をする
final result = userUseCase.getUser(mockUserId);
expect(result.name, equals('Mr.クリーンアーキテクチャ'));//✅PASS!
});
このように、クリーンアーキテクチャはテストコードの書きやすさを劇的に改善してくれます。これは、クラスがインターフェース化されていることと責務が十分に分離されているのが理由です。
ところで読者の皆さんはインターフェースについて、どの程度ご存知でしょうか?インターフェースについて十分に理解していないと、クリーンアーキテクチャの良さがピンとこないですし、テストしやすい実装がどういうものかもしっかり理解できないかもしれません。
そこで、次のセクションではインターフェースについて、詳しく見ていきましょう。
まずはインターフェースと仲良くなろう
インターフェースとは
定義: クラスが満たすべき「約束事」や「契約」を定めたもの
イメージ: 「こんなメソッドを持ってね」という設計図とも言える。
インターフェースを活用するメリット:
- テストしやすいコード
- 環境に応じた実装の切り替えが簡単
例え話:ピザ屋さん
ピザ屋さんを開くには、いろんな準備が必要ですよね。でも一番大事なのは、ピザを作れる人がいることです。
では、「ピザを作れる人」って、どんなことができなきゃいけないでしょうか?
- 生地をこねることができる
- ピザを焼くことができる
この2つができれば、その人はピザ職人としてお店で働けますよね。
逆に言えば、「ピザ職人として働きたい」と言うなら、生地をこねて、ピザを焼けることが条件です。これが、「ピザ職人とはこういうスキルを持っているべきです」というルールです。
この「ルール」が、プログラミングの世界では インターフェース(interface) と呼ばれます。
ここでは、「生地をこねること」と「ピザを焼くこと」ができる人であるべし、という契約(ルール)をピザ職人インターフェースと名付けましょう。
たとえば、これをコードで表すとこうなります:
ピザ職人インターフェース
/// ピザ職人インターフェース - ピザ職人が持つべきスキルの定義
abstract class PizzaChef {
void kneadDough(); // 生地をこねる => ピザ職人の決まり事その1
void bakePizza(); // ピザを焼く => ピザ職人の決まり事その2
}
ここでのポイント:
-
abstract
を使うことで、「これはインターフェースですよ」と示します。 -
PizzaChef
は「ピザ職人インターフェース」の名前です。 - メソッドの中身は書かれていません。これはピザ職人であるならばというスキル一覧なのでその具体的な方法はここでは問わないからです。
「ピザ屋さんには、ピザ職人が必要」
これもコードで表現できます:
ピザ屋にはピザ職人が必要:
/// ピザ屋さんクラス - ピザ職人を雇って運営する店
class PizzaShop {
// ピザ職人を1人雇う(インターフェースで定義されたスキルを持った人)
final PizzaChef chef;
PizzaShop(this.chef);
}
ここでのポイント:
-
PizzaShop
は「ピザ屋さん」を表すクラスです。 -
PizzaChef
(インターフェース)に沿った職人をchef
として受け取ります。 -
PizzaShop
は「ピザ職人が必要!」と宣言していて、誰でもいいけどピザ職人インターフェースを満たしている人じゃないとダメ、というわけです。
さて、このピザ屋さんで働きたい人がいたとして、この人は 「私はお店が求めるピザ職人です!」と伝えなければなりません。(宣言する必要がある)
これをプログラミング的に言えばピザ職人インターフェースを実装する必要があるということになります。
実際のピザ職人たち(実装):
// ピザ職人の実装 イタリアンピザ職人
class ItalianChef implements PizzaChef {
}
ここでのポイント:
-
implements
を使うことで後に続く インターフェースを実装していると宣言することができます。 -
ItalianChef
はピザ職人インターフェースを実装している
宣言したからには本当にピザ職人のスキルを持っていなければなりません。実際、このままだとエラーとなってしまいます。
実装してみましましょう。
// ピザ職人の実装 イタリアンピザ職人
class ItalianChef implements PizzaChef {
void kneadDough() {
print('ピザの生地をこねます。');
}
void bakePizza() {
print('ピザを焼きます。');
}
}
ここでのポイント:
-
@override
を使うことでインターフェースに記載されたメソッドの実装であることを示します。
ここまでのまとめ
- インターフェースは「こういうスキルを持つべき」という契約
- implementsを使って「私はその契約を守ります」と宣言
「インターフェースがなくても動くよね?」
ここまでで、なんとなくインターフェースがなんなのかわかってもらえたかなと思います。でも、「インターフェースなんて使わないでもプログラミングできるよ」って思いませんでしたか?確かにそうです。
インターフェースを一つも使わずとも実装できます。例えばこんな感じでしょうか。
//インターフェースを使ってピザ職人のスキルがあることを宣言していない
class ItalianChef {
void kneadDough() {
print('ピザの生地をこねます。');
}
void bakePizza() {
print('ピザを焼きます。');
}
}
class PizzaShop {
// ItalianChefそのものを直接使用
final ItalianChef chef;
PizzaShop(this.chef);
// ピザ屋さんの運営
void openShop() {
chef.kneadDough(); // 生地をこねる
chef.bakePizza(); // ピザを焼く
}
}
でも...
確かに動きます。
しかし、次のようなケースはどうしますか?
"このお店はグランドオープンの前でリハーサルをする。リハーサルではお店の営業の流れをシミレーションすることが目的なので、実際にピザは焼かずにピザを焼くふりだけさせたい。"
ItalianChefの中にif文を書くという人もいるかもしれません。
確かに例え話のピザ屋さんなら、ピザ職人に「リハーサルの時は焼くふりをして」ということを覚えてもらえばいいでしょう。
class ItalianChef {
final bool isRehearsal;
ItalianChef({this.isRehearsal = false});
void kneadDough() {
if (isRehearsal) {
print('(リハーサル)生地をこねるふりをします');
} else {
print('ピザの生地をこねます');
}
}
void bakePizza() {
if (isRehearsal) {
print('(リハーサル)ピザを焼くふりをします');
} else {
print('ピザを焼きます');
}
}
}
でも例え話から一旦現実世界に戻ってきて見ると...
そのロジックとは直接関係のないif文を追加すると、コードの可読性(コードの読みやすさ)が損なわれます。またテストコードではモックとして様々な動きをしてもらいたいです。その度にif文やswitch文を追加すると本当のロジックがどこかわからなくなってしまいます。
例えば、こんなにケースが増えてきたらどうでしょう?
class ItalianChef {
final bool isRehearsal;
final bool isTest;
final bool isDemo;
final bool isTraining;
ItalianChef({
this.isRehearsal = false,
this.isTest = false,
this.isDemo = false,
this.isTraining = false,
});
void bakePizza() {
if (isTest) {
print('(テスト)エラーが発生しました');
} else if (isRehearsal) {
print('(リハーサル)ピザを焼くふりをします');
} else if (isDemo) {
print('(デモ)素早くピザを焼きます');
} else if (isTraining) {
print('(研修)ゆっくりとピザを焼きます');
} else {
// 本当のロジックはここ!でも埋もれてしまった...
print('ピザを焼きます');
}
}
}
問題点:
- 本来のピザを焼くロジックが埋もれてしまう
- 新しいパターンが増える度にif文を追加する必要がある
- ItalianChefが「ピザを焼く」以外の責任も持ってしまう
- テストの時だけ使いたい「エラーを起こす職人」のために、本番コードにテスト用のフラグが入り込む
それを解決するのがインターフェース!
インターフェースを使えば:
- 各クラスは自分の責任だけに集中できる
- 本来のロジックがクリアに見える
- 新しいパターンは新しいクラスで対応
- テスト用のコードが本番コードに混入しない
これが、インターフェースが「コードをきれいに保つ」理由の一つです。
// インターフェースを使った場合
abstract class PizzaChef {
void kneadDough();
void bakePizza();
}
本番用
// 本番用 - イタリアンピザの焼き方に集中
class ItalianChef implements PizzaChef {
void kneadDough() {
print('オリーブオイルを加えて生地をこねます');
}
void bakePizza() {
print('400度の石窯でピザを焼きます');
}
}
リハーサル用
// リハーサル用 - リハーサルの動作に集中
class RehearsalChef implements PizzaChef {
void kneadDough() {
print('(リハーサル)生地をこねるふりをします');
}
void bakePizza() {
print('(リハーサル)ピザを焼くふりをします');
}
}
テスト用
// テスト用 - テストの動作に集中
class ErrorTestChef implements PizzaChef {
void kneadDough() {
throw Exception('生地作りでエラーが発生しました');
}
void bakePizza() {
throw Exception('ピザ焼きでエラーが発生しました');
}
}
使い分けも簡単:
//本番環境
void main() {
// ピザ職人のインスタンスを作成
final chef = ItalianChef();
// ピザ屋さんにピザ職人を渡す
final shop = PizzaShop(chef);
// ピザ屋さんを開店
shop.openShop();
}
//テスト環境
void main() {
test('テスト1', () {
// リハーサル用のピザ職人のインスタンスを作成
final chef = RehearsalChef();
// ピザ屋さんにピザ職人を渡す
final shop = PizzaShop(chef);
// ピザ屋さんを開店
shop.openShop();
});
test('テスト2', () {
// テスト用のピザ職人のインスタンスを作成
final chef = ErrorTestChef();
// ピザ屋さんにピザ職人を渡す
final shop = PizzaShop(chef);
// ピザ屋さんを開店
shop.openShop();
});
}
ポイント:
- PizzaShopのコードは全く変更していない
- 各職人クラスは自分の役割だけに専念
- 新しいパターン(例:AmericanChef)も簡単に追加できる
- テスト用のErrorTestChefが本番コードに影響しない
インターフェースにより、安全で、テストしやすく、柔軟なコードが書けるようになります!
最後に
最後まで読んでいただきありがとうございます。
私自身もまだまだ学び続けている身です。間違いや改善点があるかもしれませんが、そんな時は温かく見守っていただけると嬉しいです。お気づきの点があれば、ぜひコメントで教えてください!
次回:「Flutter クリーンアーキテクチャ完全ガイド③ - クリーンアーキテクチャについて」
この記事がいいなと思ったらいいねやブックマークをお願いします!
記事を作成する励みになります!
Discussion