flutter-architecture について
flutter-architectureは、Flutterアプリを規模に応じた機能優先のMVVM構成にするためのスキルです。状態管理と依存性注入にRiverpod 3.xを使い、不要な抽象化やパッケージ分割を避けながら層の境界を明確にします。
主な機能
- 01機能ごとにコードをまとめて層に分割
- 02Riverpod 3.xで状態管理と依存性注入
- 03UI、データ、必要に応じたドメイン層を定義
- 04リポジトリの書き込みとデータフローを規定
- 05プルリクエスト前のアーキテクチャ検査
活用シーン
- 01新しいFlutterコードの配置先の判断
- 02機能のビューとViewModelの構成
- 03インターフェースやドメイン層の追加判断
- 04プルリクエスト前のアプリ構成レビュー
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill flutter-architectureインストール後、Claude Code で/flutter-architectureを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
examples/composition_root.dartfeature_slice.dartreferences/module-and-layers.mdright-sizing.mdstate-di-riverpod.mdscripts/check_architecture.sh詳細
Flutter アーキテクチャ
すべての Flutter アプリを、アプリの規模に合わせた、レイヤー化された機能優先の MVVM システムとして構成します。「このコードはどこに置くべきか?」に、常に1秒で答えられるようにしてください。Flutter の公式 Guide to app architecture に基づき、状態管理と依存性注入の両方に Riverpod 3.x を唯一の仕組みとして使用します。
アーキテクチャを破綻させる失敗パターンは2つあります。構造が不十分な状態(ウィジェットがデータベースに触れる)と、構造を過剰に設ける状態(1回のリポジトリ呼び出しをラップする *UseCase、テストですでに動作するコードにインターフェースを設ける)です。このスキルはその両方を防ぎます。まずは小さく始め、名前を挙げられる役割を担う場合にだけレイヤーを追加します。
タスクに応じて、次のリファレンスをお読みください。
references/module-and-layers.md— レイヤーモデル、フォルダーとパッケージの使い分け、DAG、バレル、機能フォルダーの構成、そして「マルチパッケージ(ワークスペース)にするタイミング」の注記。references/right-sizing.md— 過剰設計を避けるための表、「テストできないものだけを抽象化する」という考え方、アプリの規模に合わせてアーキテクチャを拡張する方法。references/state-di-riverpod.md— プロバイダーグラフ、keepAliveとautoDispose、プレースホルダーをオーバーライドするコンポジションルート、アイソレートセーフなファクトリー、ProviderContainerのテスト。
PR を作成する前に scripts/check_architecture.sh を実行してください。
譲れないルール
- 「これはどこに属するか?」に1秒で答えられるようにします。 まず機能ごと、次にレイヤーごとにまとめます。アプリ全体で巨大な
screens/、models/、widgets/のバケットを作ってはいけません。機能がツリー全体に散らばり、編集のたびに名前を探すことになります。 - 最低でもUIとDataの2レイヤーを設けます。ロジックが複数のリポジトリにまたがる場合(射影、集約、複数ステップのワークフロー)に限り、domain/use-caseレイヤーを設けます。 1つのリポジトリ呼び出しを転送するだけの
*UseCaseは、境界ではなく名前の付け替えです。Flutter独自のガイダンスでも、domainレイヤーは条件付きとされ、ほとんどのアプリでは不要とされています。 - テストで実行できないものだけを抽象化し、その抽象化によって何をテスト可能にするのかを明示します。
flutter testで実行できないプラットフォームチャンネル、ネットワーククライアント、プラグインには、インターフェースとフェイクを用意します。すでにヘッドレスで実行できるコード(インメモリDB、純粋な計算処理、それを使うリポジトリ)は具象のままにします。「そのほうがきれい」は理由になりません。抽象化によって得られる境界を明確に説明できないなら、追加しないでください。 - データは一方向に流し、途中の層を飛ばしたり、逆方向に流したりしません。 データは下向き:
Service → Repository → ViewModel → View。イベントは上向き:View → ViewModel → Repository。ウィジェットが呼び出せるのはViewModelだけで、ViewModelが呼び出せるのはリポジトリだけです。双方向バインディングは禁止です。ウィジェットからデータソースにアクセスしてはいけません。 - Viewは単純に保ちます。 Viewが行うのはレイアウト、状態に応じた
if/switch、アニメーション、ナビゲーションだけです。それ以外は行いません。ビジネスロジック、フォーマットや数値計算、try/catch、データアクセスは禁止です。ウィジェットが計算やデータ取得を行うなら、レイヤーが間違っています。 - 機能ごとに1つのViewModelを設け、不変の状態を扱います。 ViewModelは非公開の可変状態を保持し、
load()、add(...)などの意図を表すメソッドを公開します。状態が遷移するたびに、値の等価性を持つ新しい不変値を割り当てます。パブリックセッターや、UIが直接変更する可変フィールドは禁止です。 - リポジトリを唯一の信頼できる情報源であり、唯一の書き込み経路とします。 変更はすべて、名前付きのリポジトリメソッド1つで行い、最初に永続化してから公開します(ストアが対応している場合は1つのトランザクションで行います)。公開してから永続化してはいけません。間にクラッシュが起きると、保存されていない状態が反映されたことになります。
- 保存せず、導出します。 件数、合計、連続記録、フィルタリング済みリストは、信頼できる情報源から計算します(ストリーム/導出プロバイダー)。同期がずれる可能性のある2つ目のカウンターとして保存してはいけません。
- 境界でマッピングし、ドメイン型を端に持ち込まないようにします。 リポジトリで、ストレージの行/JSON DTOを不変のドメイン値オブジェクトにマッピングします。生成されたDriftの行や
MapをViewModelやウィジェットまで渡してはいけません。単位の計算やロケールに応じたフォーマットをウィジェットで行ってはいけません。 - プロバイダー経由で注入し、抽象化が有効な場合はそれに依存します。 グローバル、シングルトン、ウィジェットから呼び出すサービスロケーター、注入された
Clock以外からのDateTime.now()は禁止です。コンポジションルートでオブジェクトグラフを一度だけ組み立てます。 - 機能はフォルダーにします。基盤がパッケージになるのは、コンパイルの壁が不可欠な場合だけです。 単一パッケージを標準とします。ある機能から別の機能をインポートしてはいけません。基盤レイヤーを介して共有するか、ルートIDで遷移します。依存関係グラフは厳密な下向きのみのDAGにします。
- エラーは境界で型付きの値として扱います。 リポジトリ/ユースケースは、レイヤーをまたいで例外を投げるのではなく、sealed
Result/Failureを返します。UIではすべてのケースを網羅して切り替えます。error-handling-typed-resultsを参照してください。
レイヤー構成(単一パッケージ、デフォルト)
物理的なツリー構造はproject-structure-and-packagesが管理します。これはそこで定義される、機能ファーストのレイアウトです。ここではレイヤーの意味を示しています。機能はlib/features/配下のフォルダーです。機能のViewと、その機能に1つだけあるViewModelはpresentation/に配置します。
lib/
main.dart # thin entrypoint → bootstrap()
bootstrap.dart # composition root: build infra, override placeholder providers, runApp
app.dart # MaterialApp.router, theme, top-level ProviderScope children
core/ # PURE foundation, NO Flutter: value objects, Result/Failure, the Clock seam,
# extensions, pure calculators (the sanctioned foundation, not a junk-drawer)
data/ # shared data layer: Drift db, DAOs, repositories, row→model mappers
services/ # injectable side-effect ports + live impls (services/native/ = MethodChannel)
routing/ # the single go_router config, typed routes, guards
theme/ l10n/ # ThemeExtension token sets; generated AppLocalizations + ARB
features/
tasks/
presentation/
task_list_screen.dart # View: dumb ConsumerWidget
task_list_notifier.dart # ViewModel: StreamNotifier over immutable state
widgets/ # widget CLASSES used only by this feature
application/ # optional: use-cases, ONLY when logic spans repositories
domain/ # optional: feature-local models
# data/ usually ABSENT — features read shared repos from lib/data/
基本方針:core/は上位のレイヤーに依存せず、BuildContextを使いません。data/はFlutter UIをインポートしません。機能が依存するのはcore/data/servicesであり、兄弟機能には依存しません。複数のリポジトリにまたがるロジックがある場合に限り、機能内にdomain/とapplication/(ユースケース)フォルダーを追加します。
機能スライス(Riverpod優先の「実装方法」)
シンプルなViewは1つのViewModelを読み取り、ViewModelは非公開の状態を保持して、インテント用のメソッドを公開します。
// features/tasks/presentation/task_list_notifier.dart — the ViewModel.
// build() returns the live repository stream; a mutation commits through the single write
// path and the watched stream RE-EMITS. No manual `state = ...` republish.
final class TaskListNotifier extends StreamNotifier<List<Task>> {
@override
Stream<List<Task>> build() {
ref.onDispose(() {/* controllers/subscriptions released here */});
return ref.watch(taskRepositoryProvider).watchTasks(); // track every emission
}
Future<void> add(String title) async { // intent method — no setter
final id = ref.read(idGeneratorProvider)(); // injected id source — never a UI UniqueKey
await ref.read(taskRepositoryProvider).add(Task.create(id: id, title: title));
// No republish: the committed write makes watchTasks() re-emit and build()'s stream updates state.
}
}
final taskListNotifierProvider =
StreamNotifierProvider<TaskListNotifier, List<Task>>(TaskListNotifier.new);
その背後にあるリポジトリが、唯一の信頼できる情報源であり、唯一の書き込み経路です。watchTasks()ストリームと、まずデータを永続化してからストリームを再出力させるadd()を用意します。実際の実装をテストで実行できない場合(ルール3)に限り、abstract interfaceを設けます。インメモリDBを使う場合は、具象クラスで問題ありません。
// features/tasks/task_list_screen.dart — the View is dumb: read one provider, render.
final class TaskListScreen extends ConsumerWidget {
const TaskListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasks = ref.watch(taskListNotifierProvider);
return tasks.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => ErrorView(onRetry: () => ref.invalidate(taskListNotifierProvider)),
data: (list) => ListView(
children: [for (final t in list) TaskTile(task: t)], // widget CLASS, not a _buildTile()
),
);
}
}
プロバイダーが依存性注入の役割を果たします。ref.watchで依存関係を接続し、familyでエンティティごとの状態をキー付けし、画面単位のコントローラーにはautoDisposeを使います。APIの規律(watch/read/listenの使い分け、.select、古いクロージャーによる危険性)の詳細はstate-management-riverpodを参照してください。
コンポジションルート(オブジェクトグラフを一度だけ構築する場所)
データベース、ディレクトリー、キーストアなど、非同期で構築するインフラは、bootstrap.dartでオーバーライドされるまで例外をスローするプレースホルダープロバイダーを介して注入します。テストでも同じ接合点を使います。
// data/providers.dart — placeholder: throws until bootstrap() overrides it (and every test overrides it).
final appDatabaseProvider =
Provider<AppDatabase>((ref) => throw UnimplementedError('override in bootstrap()'));
// bootstrap.dart — the ONE place async infra is built and the graph is composed.
Future<void> bootstrap() async {
WidgetsFlutterBinding.ensureInitialized();
final db = await openAppDatabase(); // plain top-level factory, no BuildContext
runApp(ProviderScope(
overrides: [appDatabaseProvider.overrideWithValue(db)],
child: const App(),
));
}
インフラ(DB、リポジトリ、クロック)にはkeepAliveを指定します。処理中に破棄される可能性のあるシングルトンをautoDisposeにしてはいけません。main()の実行順序とエラーハンドラーについてはapp-startup-and-bootstrapを参照してください。
適切な規模:フォルダーとパッケージ、抽象化の判断
アプリの規模に合わせてアーキテクチャを調整します。以下の3つの問いを順番に検討してください。
- 新しいフォルダーが必要ですか、それとも新しいファイルが必要ですか? ほとんどの場合、既存の機能/レイヤーにファイルを追加すれば十分です。
- domain/ユースケース層が必要ですか? 複数のリポジトリにまたがるロジックがある場合に限ります。1つのリポジトリを使うCRUDでは不要です。
- インターフェースが必要ですか? 実際の実装を
flutter testで実行できない場合、または開発用と本番用の実装を本当に切り替える場合に限ります。そのインターフェースで可能になるフェイクに名前を付けてください。 - 別のパッケージが必要ですか? コンパイル時の境界が重要な役割を果たす場合に限ります。境界を越えると正しさに関するバグが発生するもの(単位や金額の計算の統一、データレイヤー全体、スケジューラーなど)が該当します。
references/right-sizing.mdにある、過剰な設計を避けるための表を参照してください。
複数パッケージを使う場合(workspace)。 大規模なアプリ(多数のチーム、ゴールデンテストを行う純粋なDartコア、再利用可能なデザインシステムなど)では、Dart pubのworkspace配下のパッケージに基盤機能を分割することがあります。その場合、各メンバーは
resolution: workspaceを設定し、ルートにpubspec.lockを1つだけ置きます。各パッケージは、非公開のsrc/を覆う単一のバレル(core.dart)を公開します。他のパッケージのsrc/をインポートしてはいけません。純粋なDartのcoreパッケージはflutterに依存しないため、誤ったインポートはコンパイルエラーになります。DAGは非循環に保ちます(core→ 内部依存なし、その他すべて →core、アプリシェル → すべて)。これは大規模なアプリへ成長する過程で選択できる方法であり、小規模なアプリに必要なものでは決してありません。フォルダーを使う単一パッケージがデフォルトです。
アンチパターン
- リポジトリ呼び出しを1回だけラップする
*UseCase/ドメイン層。 単なる儀式であり、リポジトリメソッドの名前を変えただけです。ロジックが複数のリポジトリにまたがる場合にのみ、この層を追加してください。 - テストで実行できるコードに対して、内容が「Repository」という単語だけの
abstract interface class。 この境界に利点はありません。Map ベースのフェイクでは、実際のスキーマが拒否する行まで受け入れてしまうことがあります。実際のインメモリストアを使って、具象クラスをテストしてください。 - View に置かれたビジネスロジック、フォーマット処理、数値や単位の計算。 ViewModel または
coreに移してください。 build()またはinitState()内で呼び出されるhttp/sqflite/SharedPreferences/プラグイン。 データアクセスはリポジトリの背後に置き、プロバイダー経由で利用してください。- 公開してから永続化する処理/コミットが返る前の楽観的な書き込み。 この間にクラッシュすると、保存されていない状態が加算されます。先に永続化してから、ストリームで再公開してください。
- 導出可能なデータを複製して保存するカウンター(行の隣にある
count列など)。値がずれていきます。信頼できる情報源から導出してください。 BuildContext/ウィジェットを使うためにpackage:flutter/material.dartをインポートする ViewModel。 ViewModel は UI フレームワークへの依存を最小限にします。BuildContextをデータ層に渡すのはさらに悪い方法です。- Riverpod と併用する2つ目の DI コンテナーとしての
get_it/injectable/package:provider、またはウィジェットから参照されるグローバルシングルトン/変更可能なstaticストア。 DI の仕組みはプロバイダーに一本化してください。 - 循環依存を生む機能間インポート。 foundation 層を介して共有するか、ルート ID を使って遷移してください。
- 「整理のため」に小規模なアプリをパッケージへ分割すること。 コンパイル時の保証を何も得られないまま、pubspec とコード生成が増えます。重要な境界壁として必要な場合にのみ、パッケージに切り出してください。
完了の定義
- 新しいコードは適切な
features/<x>/またはcore/dataの場所に配置されており、機能単位でグループ化されています。 - View はデータアクセス、ビジネスロジック、フォーマット処理、
try/catchを含まない、単純なConsumerWidgetです。 - ViewModel は不変の状態とインテントメソッドを公開する
Notifier/AsyncNotifier/StreamNotifierです。公開セッターはなく、Riverpod の経路ではnotifyListeners()を使いません。 - すべての変更処理は、永続化してから公開する1つのリポジトリメソッドを経由します。監視中のストリームが再送出するため、手動で再公開する必要はありません。導出値の読み取りには、保存済みカウンターではなくストリーム/派生プロバイダーを使います。
- リポジトリはストレージの行/DTO を不変の値オブジェクトにマッピングします。Drift/DTO/
Map型が ViewModel やウィジェットに渡ることはありません。 - インターフェースは、実際の実装をテストで実行できない場合(または開発環境用と本番環境用で実際に切り替える場合)にのみ設け、それぞれに名前付きのフェイクを用意します。
- 依存関係はコンポジションルートからプロバイダー経由で注入します。新しいグローバルやシングルトンは追加せず、注入されたクロック以外では
DateTime.now()を使いません。 - ドメイン/ユースケース層は、ロジックが複数のリポジトリにまたがる場合にのみ追加されています。コンパイル境界が重要な役割を果たす場合を除き、アプリは単一パッケージです。
-
scripts/check_architecture.shが成功し、dart analyzeで問題がありません。
関連スキル
- Notifier/AsyncNotifier API、watch/read/listen の使い分け、
family+autoDispose、古いクロージャーに関する注意点については、state-management-riverpodを参照してください。 - dumb View の構築(ルール 5)について詳しくは、
widget-compositionを参照してください。_buildX()ヘルパーより const ウィジェットクラスを使うこと、build()を簡潔に保つこと、このレイヤー DAG 内でウィジェットがどこに位置するかを解説しています。 - 機能フォルダーの作成から、型付きの go_router 登録までの手順については、
scaffold-feature-moduleを参照してください。 - pubspec を監査成果物として使う方法、バレル、一方向のレイヤリングの仕組みについては、
project-structure-and-packagesを参照してください。 - main() の実行順序、グローバルエラーハンドラー、ルートでの DI オーバーライドについては、
app-startup-and-bootstrapを参照してください。 - 境界を越えて返される、シールドされた
Result/Failureの基盤については、error-handling-typed-resultsを参照してください。 - シールド型、網羅的な switch、不変の値型については、
dart3-idioms-and-coding-standardsを参照してください。 - grep で判別できる
Screen/Notifier/Repository/Service/Failureという役割別の接尾辞については、naming-conventionsを参照してください。 - DAO/リポジトリの接合部と、変更ごとのトランザクションを用いた永続化については、
persistence-driftを参照してください。
参考資料
- Flutter — アプリのアーキテクチャガイド
- Flutter — アーキテクチャに関する推奨事項
- Flutter — UI レイヤーのケーススタディ(dumb View に含められるもの)
- Flutter — オフラインファーストのサポート(ローカルストアを信頼できる情報源とする)
- Riverpod 3 ドキュメント
- Dart — Pub ワークスペース(モノレポのサポート)
Provider/ChangeNotifier 付録
同じルールは、Riverpod の代わりに package:provider+ChangeNotifier を使うコードベースでも、Flutter 公式ガイドのスタックに当てはめられます。ルール 1~9、11、12 に変更はありません。異なるのは、状態管理と DI の「方法」だけです。
- ViewModel =
ChangeNotifier。 引き続き非公開の可変フィールドを保持し、意図を表すメソッドを公開します。状態が実際に変化した後にnotifyListeners()を1 回だけ呼び出します。build()内やタイトなループ内では決して呼び出しません。変更不可のビュー(List.unmodifiable)を返すゲッターを使って状態を公開します。ViewModel が保持するドメインモデルには、引き続き不変性を適用します(copyWith、値の等価性)。 - DI = コンストラクターインジェクションを
providerでルート付近に設定します。 アプリのルートに置いたMultiProviderでサービスとリポジトリを作成します。画面のChangeNotifierProviderでは、context.read<XRepository>()を使って ViewModel を生成します。テストでフェイクを注入できるよう、抽象型のリポジトリに依存します。 - read と listen の使い分け: 変更時に再ビルドするには
context.watch<T>()/Consumerを使い、コールバック内ではcontext.read<T>()を使います。プロバイダーを読み取るためにBuildContextをデータレイヤーへ渡すことは避けてください。データを渡します。 - 禁止事項は引き続き適用されます。ウィジェットから
get_it/サービスロケーターを使わないこと、グローバルな可変状態を使わないこと、View でデータにアクセスしないことです。このスキルのその他のルール(レイヤリング、単一の書き込み経路、保存せずに導出すること、境界でマッピングすること、テストできないものだけを抽象化すること、フォルダーとパッケージの使い分け)はすべて同じです。