はじめに
Flutter開発歴2年で初めて0からFlutterのモバイルアプリを作ってみようと思った時に、どういうアーキテクチャを選ぶのが最適なのか悩みました。
今まで携わっていたFlutterアプリケーションの構成はfeature-first + MVVMでしたが、この構成では、主に2つの課題がありました。
- ViewModelとRiverpodの責務が重複して曖昧になることです。
- ViewModelが状態管理や非同期処理のロジックを持つ一方で、Riverpodも同様の責務を担えるため、どちらで状態を扱うべきか判断が難しくなっていました。結果として、処理の分担やデータフローが不明瞭になりがちでした
- feature-first構成とFlutterの設計思想の相性の問題
- Flutterは「状態中心」で構成されるフレームワークであり、ウィジェット階層を基点に状態が自然に流れる設計になっています
- しかし、feature-first構成では異なる機能間でViewModelを跨いで処理を呼び出すケースが増え、共通化や依存関係の整理が難しくなりました。結果として、「目的のメソッドがどこにあるのか」が分かりづらくなり、開発効率にも影響していました
これらの問題は「MVVMが悪い」「feature-firstが悪い」という話ではなく、
ただ、Flutterというフレームワークの設計思想にそのまま当てはめるには無理があると感じました。
そこで今回、Flutterの特徴を踏まえたうえで、より自然で、Flutterに最適化されたアーキテクチャ を再構築しました。
これからFlutterでモバイルアプリを設計しようとしている方に、少しでも参考になれば幸いです。
対象者
本記事は、Flutterで新規アプリを0から設計したい方、Riverpodを使って責務分離を明確にしたい方、MVVMとの違いを整理したい方に特におすすめの内容です。
前提となる技術要素
本記事で紹介する「状態駆動クリーンアーキテクチャ」は、以下の2つを中核とした構成を前提としています。
- Riverpodによる状態管理
- go_routerによるルーティングおよびスタック管理
なお、「状態駆動クリーンアーキテクチャ」という名称は一般的な用語ではなく、本記事において私が定義した独自の概念です。
Flutterの「UI = f(state)」という設計思想を踏まえ、状態を軸にクリーンアーキテクチャの各層を再構成した考え方を指します。
Flutterの設計思想とMVVMの理解
Flutterの設計思想を踏まえて、MVVMがどのようなアーキテクチャパターンなのかを整理します。
詳細な解説は以下の記事にまとめているため、ここでは本記事を読むうえで必要な要点のみ共有します。
Flutterの設計思想
Flutterは「UI = f(state)」という明確な思想に基づいて設計されています。
つまり、UIは状態(state)の結果として描画されるものであり、状態の変化に応じてUIを再構築するリアクティブなフレームワークです。
このため、状態の流れを単一方向に保ち、状態をどう管理しUIに反映するかが設計の中心となります。
MVVMとは
MVVM(Model–View–ViewModel)は、UIとロジックを分離し、表示(View)・状態とプレゼンテーションロジック(ViewModel)・ドメイン/データ(Model)の責務を明確にするためのアーキテクチャパターンです。
ポイントは「UIは状態の結果として描画される」という考え方に立ち、ViewModelが単一の真実の状態(Single Source of Truth)を提供することです。
参考: 「MVVM=双方向データバインディング」じゃない!Flutterで正しく理解するMVVM
実装例で確認する問題点
まずは課題感のイメージを持ってもらうために、実際のコードベースで確認してみましょう。
具体例1:Riverpod vs ViewModel — 責務が曖昧になるケース
Flutter × MVVM構成を採用していた当初は、StateProviderやChangeNotifierProviderなど、状態管理専用の Provider に ViewModel を組み合わせる構成が一般的でした。
しかし、AsyncNotifier や Notifier のようにビジネスロジックを直接扱える Provider が登場したことで、ViewModel の存在意義が薄れつつあると考えています。
Before:ViewModel中心の構成(ChangeNotifierを使用)
class UserViewModel extends ChangeNotifier {
final _repository = UserRepository();
User? _user;
bool _isLoading = false;
User? get user => _user;
bool get isLoading => _isLoading;
Futurevoid> fetchUser() async {
_isLoading = true;
notifyListeners();
_user = await _repository.fetchUser();
_isLoading = false;
notifyListeners();
}
}
final userViewModelProvider = ChangeNotifierProvider((_) => UserViewModel());
この時代の構成では、
• ViewModelが状態(_user, _isLoading)を保持
• UIはその状態を購読して描画
というように、状態とロジックがViewModelに集約されていました。
After:AsyncNotifierによる直接的な状態管理
Riverpod 2.0以降、AsyncNotifierやNotifierが登場し、
状態管理と非同期ロジックをProvider単体で完結できるようになりました。
final userNotifierProvider =
AsyncNotifierProviderUserNotifier, User>(UserNotifier.new);
class UserNotifier extends AsyncNotifierUser> {
final _repository = UserRepository();
FutureOrUser> build() async {
return _repository.fetchUser();
}
Futurevoid> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() => _repository.fetchUser());
}
}
ここではViewModelが不要となり、
UIは次のようにProviderを直接監視して描画します。
class UserPage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final userState = ref.watch(userNotifierProvider);
return userState.when(
data: (user) => Text(user.name),
loading: () => const CircularProgressIndicator(),
error: (error, _) => Text('Error: $error'),
);
}
}
従来、ViewModelは「状態の保持」と「プレゼンテーションロジックの管理」という2つの責務を担っていました。
しかし、RiverpodのNotifier系(Notifier / AsyncNotifier)の登場によって、これらの責務の多くをProvider単体で完結できるようになったため、結果的にViewModelの存在意義が薄れつつあると考えています。
もしそれでもViewModelを残す場合、責務の分離としては次のような構造になると私は考えています。
- ViewModel:ビジネスロジックを担当(ドメイン層に近い責務)
- Riverpod:状態管理とViewModelのメソッド呼び出しを担うオーケストレーション層
ただしこの構成では、
ViewModelがプレゼンテーションロジックを、Riverpodが状態管理をそれぞれ部分的に担う形となり、結果として、本来ViewModelが一貫して担うべき「状態」と「プレゼンテーションロジック」の責務を単体で完結できていないと考えています。
その結果、Flutter + Riverpod環境においては、MVVMという構造を維持する必然性が低下し、MVVMを採用した場合、似た責務を持つ層が二重に存在する状態が生まれやすくなり、開発者の混乱やメンテナンス性の低下につながる要因となると考えています。
具体例2:feature-first vs UI = f(state)
Flutterは「UI = f(state)」、すなわちUIは状態の結果として描画されるという設計思想に基づいています。
この思想のもとでは、「状態がどこにあり、どのようにUIへ伝播するか」を単純に保つことが重要ですが、feature-first構成では機能単位でフォルダを分けることを優先するあまり、状態やロジックが featureを跨いで依存するようになり、この状態の流れが分散し、UIが状態を直接参照できなくなるケースが多発します。
lib/
├─ features/
│ ├─ user/
│ │ ├─ user_view_model.dart
│ │ └─ user_repository.dart
│ ├─ chat/
│ │ ├─ chat_view_model.dart
│ │ └─ chat_repository.dart
│ └─ common/
│ └─ widgets/
たとえば、チャット機能の画面でユーザー情報を参照したい場合、
ChatViewModel が UserViewModel に依存するような構造が生まれます。
final user = ref.read(userViewModelProvider).currentUser;
final messages = await _repository.fetchMessages(user.id);
state = AsyncData(messages);
このように「Chatの状態を作るためにUserの状態が必要」になると、
UIが chatState だけで完結せず、他の feature の状態を横断的に参照する構造になります。
その結果、状態間の依存関係が複雑化し、データフローが多段化することで、どの Provider が最終的な状態を保持しているのかが分かりにくくなり、意図しない不具合が発生しやすい構造になると考えています。
Flutterに最適化したアーキテクチャパターンとディレクトリ構成
Flutterの思想を前提にすると、クリーンアーキテクチャを採用すべきだと考えています。
Clean Architectureとは
Flutterに最適化した構成を説明する前に、
まず「Clean Architecture」とは何か、その基本的な考え方を簡単に整理します。
Clean Architectureは、Robert C. Martin(通称 Uncle Bob)が提唱したアーキテクチャパターンで、依存方向をフレームワークやUI、DBアクセスなどの技術的な詳細から、ビジネスルール(エンティティやユースケース)へ向けることを原則としています。
これにより、外部技術(フレームワーク、UI、DB)に依存しない、独立したドメインロジックを保つことを目的としています。
このアプローチの最大のメリットは、「変化に強い構造」を実現できる点です。
UIデザインや外部APIの仕様変更といった外側の要素が変わっても、アプリケーションの中核であるドメインロジックは影響を受けにくくなります。
結果として、修正範囲が明確になり、機能追加やリファクタリングの際にバグが発生しにくい安定した構造を維持できます。
また、層ごとに責務が明確に分かれるため、UI・データ・ロジックの分業がしやすく、
チーム開発や長期運用にも適した設計といえます。
より詳しい解説については、以下の記事が参考になります。
私自身もこの概念を完全に理解できたとは言えず、この記事を書くにあたって改めて関連書籍を2冊購入し、理解を深めている最中です、、、
ディレクトリ構成
lib/
├── main.dart
│
├── core/ # 共通基盤(例外、ネットワーク、ルーティング、ユーティリティなど)
│
├── presentation/ # UI層(画面・ウィジェット・状態管理)
│ ├── pages/ # 各機能の画面
│ │ ├── home/
│ │ ├── user/
│ │ └── settings/
│ │
│ ├── controllers/ # 状態管理(Riverpod Notifier / Provider)
│ │ ├── domain/ # ドメイン単位の状態(例: User, Content, Config)
│ │ │ ├── user_controller.dart
│ │ │ ├── content_controller.dart
│ │ │ └── config_controller.dart
│ │ │
│ │ └── feature/ # 機能単位の状態(画面・機能ロジックに対応)
│ │ ├── home_controller.dart
│ │ ├── user_detail_controller.dart
│ │ └── settings_controller.dart
│ │
│ └── shared/ # 共通UI(共通Widget・ページなど)
│ ├── widgets/
│ └── pages/
│
├── application/ # アプリケーション層(ユースケース・サービス)
│ ├── user/
│ ├── content/
│ └── settings/
│
├── domain/ # ドメイン層(ビジネスロジック・モデル)
│ ├── entities/
│ ├── value_objects/
│ ├── repositories/
│ └── services/
│
└── data/ # データ層(API・DB・永続化)
├── datasources/
├── repositories/
├── mappers/
└── remote/
レイヤーの関係図
状態駆動構造の流れ
この構成の狙い
このアーキテクチャ構成の目的は、Flutterの「宣言的UI」と「Clean Architectureの原則」を両立させることです。
特に、次の4点を意識しています。
- 責務の分離ができること
- UI(見た目)・状態(Controller)・ビジネスロジック(UseCase)・ドメインルール(Entity)・外部I/O(Repository)を明確に切り分け、依存方向を一定に保つ
- どの処理をどの層に置くかが一貫して判断できる
- SSOT(Single Source of Truth)が担保できること
- すべての状態はRiverpodのProviderを中心に一元管理される
- UIは状態を直接操作せず、「状態を変えるトリガー」だけを発火する
- Flutterの「UI = f(state)」という設計思想を自然な形で実現している
- これによりデータの整合性と再現性が保証される。
- 単方向データフローに最適化されていること
- State → UI → Event → State のサイクルが明確で、どこからでも追跡可能
MVVMとfeature-first構成の課題をどう解決しているか
1. MVVMとRiverpodの責務重複を解消
この構成では、RiverpodのNotifierをControllerとして扱い、Presentation層における状態管理とプレゼンテーションロジックの責務を統合しています。
Clean ArchitectureにおけるController(またはPresenter)は、UI層とアプリケーション層(UseCaseなど)の間に立ち、ビジネスロジックの結果をUIが扱える形式に変換・伝達する役割を持つと理解しています。
Flutter + Riverpod構成では、この概念的なControllerの責務をNotifierがそのまま実現できるため、UIからのイベントを受け取り、アプリケーション層の処理結果を状態として保持し、UIはその状態を監視して自動的に再描画されます。
このように、RiverpodをPresentation層の中心に据えることで、MVVMで曖昧になりがちだった「状態をどちらが持つのか」という問題を解消し、状態の唯一の更新源(Single Source of Truth)を明確に保つことができます。
2. feature-first構成の状態分散を解消
もう一つの課題は、feature-first構成によって状態が複数の機能間に分散し、UIが異なるViewModelを横断的に参照することで依存関係が複雑化する点でした。
本構成では「state-first(状態中心)」という考え方を採用し、状態の流れをController単位で整理しています。
UIは主となるControllerを軸に描画されつつ、必要に応じて他のControllerの状態も参照することができますが、それぞれのControllerが独立した責務を持つため、機能間で状態を直接共有・更新するような構造にはなりません。
これにより、状態の流れが整理され、依存関係の追跡や修正が容易になります。
結果として、Flutterが持つ宣言的UIの思想と、Clean Architectureが目指す責務分離を自然に両立できるようになっています。
状態駆動クリーンアーキテクチャの詳細
概要
状態駆動クリーンアーキテクチャ(State-Driven Clean Architecture)は、クリーンアーキテクチャの依存方向原則を守りながら、Flutterの「宣言的UI」と「状態中心の構造(UI = f(state))」、そしてコロケーション(関連するUIとロジックを近接配置する構造)に最適化したアーキテクチャです。
このアーキテクチャは、次の3つの考え方を組み合わせて再構成されています。
| 構成要素 | 役割 | Flutterでの対応 |
|---|---|---|
| DDD(Domain-Driven Design) | ビジネスルールやドメインモデルを中心に設計する | domain/ 層(Entity・ValueObject・Repository Interface) |
| MVU(Model-View-Update) | 単方向データフローでUIを状態に同期させる | presentation/ 層(Notifier・Widget) |
| Riverpod | 状態の単一管理と依存関係の明示 | 状態駆動構造の基盤(Controller = Notifier) |
各レイヤーの役割
状態駆動クリーンアーキテクチャでは、それぞれのレイヤーが「責務の分離」と「単方向依存」を守るように設計されています。
以下では各レイヤーの役割・構成・禁止事項を整理します。
| レイヤー | 主な責務 | 代表的ディレクトリ構成 |
|---|---|---|
| core | アプリ全体の基盤機能(例外・ネットワーク・Firebase・ルーティング設定など)。他層に共通サービスを提供する。 | core/{network, exceptions, firebase, router, utils} |
| presentation | ユーザー操作を受け取り、Controller(Notifier)を介して状態を管理・監視し、UIを再描画する。 | presentation/{pages, controllers, shared} |
| application | 機能(Feature)単位にUseCaseを定義し、ビジネス処理やフロー制御、権限・検証などを行う。 | application/ |
| domain | ビジネスルール・不変条件・ドメインモデル(Entity / ValueObject)を定義し、外部依存のない純粋なロジック層。 | domain/{entities, value_objects, repositories, services} |
| data | 外部I/O(API / DB / Cache)を扱い、DTOとDomain間の変換・永続化処理を担う。 | data/{datasources, repositories, remote} |
presentation(UI / Controller)
- 責務
- ユーザー操作を受け取り、状態(Riverpod) を更新し、UIを再描画する(UI = f(state))
- UIイベントを Application の UseCase に橋渡しする(境界アダプタ)
- 構成
- presentation/pages/…:タブ単位の画面(home/, scout/ など)とその widgets/
- presentation/controllers/domain/:機能に依存しないドメイン情報の状態(例:user_controller.dart)
- presentation/controllers/feature/:画面/フロー固有の一時状態(例:entry_controller.dart)
- presentation/shared/:共通UI(ボタン、ダイアログ、共通ページ)
- Controller の責務
- UseCase の起動・AsyncValue の遷移管理(loading/success/error)
- 画面スコープの状態保持(フィルタ、入力値、選択状態)
- I/O
- In:ユーザーイベント(タップ/入力)・既存の Domain 状態
- Out:UseCase へのコマンド・UI の状態更新
- Don’t
- ビジネスルール実装、外部I/O、DTO操作、複数トランザクション、権限判定の内包
application(UseCase / Service)
- 責務
- 機能(feature)単位のビジネス操作を定義し、処理の流れを編成する(オーケストレーション)
- 入力検証・権限チェック・例外/失敗時の再試行・複数リポジトリ連携など、副作用を伴うフローを安全に実行する
- Flutter/Riverpod から独立した 純ロジック層 として、Presentation に「結果」を返す
- 構成
- application/
/usecases/…:単一目的の操作(例:submit_entry_usecase.dart, reply_scout_usecase.dart) - application/
/services/…:複数 UseCase を束ねるフロー調整(例:entry_flow_service.dart, scout_flow_service.dart)
- application/
- I/O
- In:Presentation(Controller)から渡される入力(Command/パラメータ)
- Out:Domain モデル/結果オブジェクト(UIで消費しやすい形まで整形可。ただしUI依存は持たない)
- Don’t
- HTTP/DB直接呼び・フレームワーク依存(BuildContext等)・UIメッセージの直書き
domain
- 責務
- アプリの意味モデル(ビジネス上の概念)と不変条件をコードで表現する中核層
- ルールを Entity / ValueObject / Domain Service に分散させ、外部依存を一切持たない
- データ取得・保存は Repository の抽象インターフェース で宣言し、実装は data 層に委譲する
- 構成
- domain/entities/:状態+振る舞いを持つモデル(例:User, Job, Scout, Entry)
- domain/value_objects/:値の正当性を保証する小さな型(例:UserId, Email, SalaryRange)
- domain/repositories/:外部I/Oの抽象(例:UserRepository, JobRepository)
- I/O
- In / Out ともに Domain 型だけ(Entity / ValueObject / enum / sealed class など)
- Don’t
- HTTP/Dio/DB/SharedPreferences 等の外部依存、UI知識(文言/色/コンテキスト)
data
- 責務
- HTTP / DB / Cache など外部I/Oを一手に担当し、DTO ↔ Domain 変換をdata層内で完結させる
- Domain層の Repository 抽象を実装し、Domainモデルを返す(DTOは外に出さない)
- 構成
- data/datasources/:外部リソースへのアクセス実装(ローカルDB操作など)
- data/repositories/:Domain の Repository インターフェース実装(UserRepositoryImplなど)
- data/mappers/:DTO ↔ Domain モデルの変換ロジックを集約
- data/remote/:OpenAPI Generator により生成されたクライアント
- I/O
- In:DomainのRepository抽象からの要求
- Out:repositories ↔ datasources ↔ remote の連携、mappersで変換
- Don’t
- UI/Riverpodへの依存、ビジネスルール実装(Domainの責務)、UseCaseの手続き・オーケストレーション(Applicationの責務)
core
- 責務
- アプリ横断の基盤機能の提供と初期化(例外、ネットワーク、ログ、Firebase、ユーティリティ、ルーティング)
- 共通のクライアントやサービスを Provider として供給 し、上位層から再利用可能にする
- 構成
- core/exceptions/:アプリ共通の例外型(AppExceptionなど)
- core/network/:HTTP クライアント(Dio)設定・インターセプタ・認証ヘッダー管理
- core/firebase/:Crashlytics / Analytics などのSDK初期化・共通メソッド
- core/utils/:日付、UUID、フォーマッタなどの汎用関数
- core/router/:アプリのルート定義(app_router.dart、route_paths.dart)
- I/O
- In:各層からの利用要求(例外送出・ログ・HTTPクライアント注入)
- Out:Dio / Logger / Crashlytics / Clock などの 共通サービスをProviderで提供
- Don’t
- 機能固有ロジック、UI文言、DTO↔Domain変換(dataの責務)、UseCase / Repository の実装(application/dataの責務)
構成の理由:Flutterとの整合性(宣言的UI × 単方向データフロー × 責務の分離)
状態中心の設計思想
Flutter は「UI = f(state)」という宣言的 UI の思想を前提としており、UI は常に「状態の結果」として描画されます。
したがって、設計の出発点は「どの UI を表示するか」ではなく、「どの状態をどのように保持し、どのように更新するか」にあります。
そのため、状態を適切に定義し、管理単位を明確に分離することが最も重要です。
Flutter では状態をアプリケーション全体の唯一の真実の情報源(SSOT: Single Source of Truth)として扱うのが原則であり、UIに閉じず、アプリケーション全体で共有・再利用されるべきものと考えています。
一方で、フォーム入力やチェックボックスの選択状態といった一時的な値は、useState や StatefulWidget のようにウィジェット内部で完結させる方が自然です。
Controller の配置方針
この状態中心の思想に基づき、Controllerを特定のfeature配下に置く構成は採用していません。
Controllerを機能単位で分割してしまうと、状態が機能ごとに分断され、同じドメイン情報が複数箇所で管理される結果となり、Flutterの状態中心アーキテクチャと矛盾してしまいます。
そのため、本構成では Controllerを常に汎用的かつ横断的な単位として設計し、アプリ全体で一貫した状態管理を行う前提にしています。
UI 構成の考え方(ルーティング単位で整理)
一方で、UI は状態そのものではなく、ルーティング構造に従って整理するのが合理的です。Flutterのgo_routerはホームタブや設定タブのようにスタック構造で画面を管理する仕組みを持つため、presentation/pages/
これにより、画面遷移やルーティングの構造とファイル構成が一致し、UI レイヤーの責務を「状態の反映」と「イベント発火」に明確に限定できます。
単方向データフローと依存方向の整合性
クリーンアーキテクチャは依存方向を内向きに制御する設計思想を持ち、結果としてデータと制御の流れが一方向に整理される構造を作ります。
この思想は Flutter の「単方向データフロー(UI = f(state))」と非常に相性が良く、状態の流れと責務の分離を明確に保つ上で両者が自然に噛み合う設計となっています。
shared 層の扱い(Presentation 限定)
本構成では、アプリ全体を横断する shared 層は設けていません。
再利用可能な責務は各層に明確に分担されており、domainやdataはもともと汎用的で共通性が高く、applicationもUseCaseやService単位で構成されているため、全体共有の shared 層を追加する必要がありません。
その一方で、UI に関する共通要素だけは再利用の観点から presentation/shared/ に配置しています。
ここではボタンやダイアログ、スナックバーといった見た目や操作に関わる UI コンポーネントのみを扱い、状態共有のようなロジックは controller/domain/ に集約しています。
この方針により、Clean Architecture の責務分離を保ちつつ、UI のみ再利用性を高める構造を実現しています。
この分割は、Flutter の「状態駆動」「宣言的UI」思想に忠実であり、クリーンアーキテクチャの依存方向を崩さず、go_router / Riverpod / AsyncNotifier に整合する構造であり、コロケーションによる現実的な開発効率を両立していると考えています。
結論として、Flutterにおける最適なアーキテクチャは「状態」を中心に構築されたClean Architectureだと考えています。
UI・状態・ユースケース・ドメインを明確に切り分け、Riverpodを用いて単方向データフローを徹底することで、アプリの一貫性、拡張性、そしてテスタビリティを高い水準で維持できる構成にしています。
以上が私が考えるFlutterに最適化されたアーキテクチャパターンとディレクトリ構成です。
まとめ
今回紹介した「状態駆動クリーンアーキテクチャ(State-Driven Clean Architecture)」は、従来のClean Architectureの原則を維持しながら、Flutterの宣言的UIと状態中心の構造(UI = f(state))に適合させた設計です。
Flutterでは「UIが状態に従属する」ため、どの層よりも状態(State)の設計と責務分離がアーキテクチャ全体の品質を左右すると考えています。
この構成では、以下のような意図を持ってレイヤーを整理しています。
- 状態中心の思想に沿う
- 状態を中心にアプリを設計することで、UIの再描画・イベント伝播・ロジックの境界が明確になる
- Flutterが前提とする単方向データフローと完全に一致する
- 責務を明確に分離する
- Controller(Notifier)は状態を扱う唯一の場所(SSOT)
- Application層は「何をするか」を決定するユースケース単位のロジック
- Domain層はビジネスルールを、Data層は外部I/Oを担当する
- 機能単位ではなく構造単位でコロケーション
- 機能(feature)ではなく、**責務(responsibility)**を基準にディレクトリを分割。
- 各層が明確に独立し、保守性・再利用性を両立する
- MVVMを超える構造的単純さ
- FlutterではViewModelの存在理由がRiverpodによって消失している
- 「UI=状態の反映」という思想を軸に、ViewModelを不要とする設計が自然
Flutterの世界では、ロジックがUIを動かすのではなく、状態がUIを導く。それが「状態駆動クリーンアーキテクチャ」の核心です。
このドキュメントが、これからFlutterでアプリを設計・構築しようとしている方々にとって
少しでも指針や助けになれば幸いです。
元の記事を確認する