scaffold-feature-module について
Flutter アプリに一貫した構成の機能モジュールを作るためのスキルです。Riverpod、リポジトリ、型付きルートを使い、画面や ViewModel、共有データ、他機能との境界を定め、生成・検証スクリプトも備えます。
主な機能
- 01標準化された機能フォルダー構成
- 02Riverpod Provider と Notifier ViewModel
- 03リポジトリ経由の読み書き
- 04型付き go_router ルート登録
- 05ひな形生成と検証スクリプト
活用シーン
- 01統一構成で画面を追加
- 02機能状態をリポジトリのストリームに接続
- 03プルリクエスト前の機能境界チェック
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill scaffold-feature-moduleインストール後、Claude Code で/scaffold-feature-moduleを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
examples/tasks_notifier.darttasks_screen.dartreferences/add-persisted-record.mdanatomy-and-wiring.mdrouting-and-l10n.mdscripts/scaffold_feature.shverify_feature.sh詳細
機能モジュールのスキャフォールド
機能を毎回同じ方法で構築します。lib/features/<feature>/ フォルダーを作成し、
presentation/ にシンプルな View、その View と 1:1 に対応する Notifier/AsyncNotifier ViewModel、末端の widgets/、スコープ付きプロバイダーを配置します(機能固有のモデルは、必要に応じて domain/ に配置します)。機能はフォルダーであり、パッケージではありません。lib/data/ の共有リポジトリと lib/core/ の共有基盤を読み取り、すべての書き込みをそれら経由で行い、依存関係は下位方向のみとします。別の機能への横方向の依存は禁止です。
(単一パッケージのツリーについては project-structure-and-packages が管理します。)
状態管理ライブラリに依存しないコア原則(どのライブラリでも適用されます):View はシンプルに保ちます。各機能につき 1 つの ViewModel が非公開の可変状態を所有し、新しい不変値を割り当てるインテントメソッドを公開します。読み取りはリポジトリ(唯一の信頼できるデータソース)から導出し、書き込みは 1 つのリポジトリメソッド(唯一の書き込み経路。公開前に永続化)を経由します。機能は抽象化に依存し、ルート ID で遷移します。以下の Riverpod を優先した「方法」では、Notifier、スコープ付き StreamProvider、DI としてのプロバイダーを使ってこれを組み立てます。
作業内容に応じて、以下のリファレンスを参照してください。
references/anatomy-and-wiring.md— 固定のフォルダー構成、Notifier とスコープ付きストリーム、watch/read/listen、family+autoDispose と keepAlive、単一の書き込み経路、ViewModel のテスト。references/routing-and-l10n.md— 型安全な go_router の登録(state.extraではなくパスパラメーター)と ARB の整合性を保つワークフロー。references/add-persisted-record.md— 機能が新しいレコードを読み取る場合の、任意のモデル → テーブル → DAO → リポジトリの連鎖(persistence-driftに準拠)。
<feature> を指定して scripts/scaffold_feature.sh <feature> を実行し、骨組みを生成してから、PR の前に scripts/verify_feature.sh を実行します。
譲れないルール
- 機能はフォルダーであり、新しいパッケージではありません。
lib/features/<feature>/に配置し、lower_snake_caseで画面名を付けます(tasks、task_detail、settings、onboarding)。新しいフォルダーの追加は通常の作業ですが、新しいパッケージの追加は境界を意図的に決める必要があります(project-structure-and-packagesを参照)。番号付きフォルダーや固定パッケージ数の規則を独自に作らないでください。アプリパッケージではlib/src/を使用しません。これは複数パッケージで使われる規則です(project-structure-and-packagesを参照)。 - 構成は固定し、ファイルごとに主要な公開型を1つにします。
lib/features/<feature>/presentation/の下に、<feature>_screen.dart(ロジックを持たない View)、<feature>_notifier.dart(1対1で対応する Notifier/AsyncNotifier の ViewModel。naming-conventionsに従い、ファイル名は主要な宣言名と一致させます)、widgets/(末端の View)、<feature>_providers.dart(この機能にスコープを限定したプロバイダー。グローバルにはしません)を置きます。機能固有のモデルは、必要に応じてdomain/<feature>.dartに配置します。構成を予測しやすくすれば、レビュー担当者は必要なものをすぐに見つけられます。 - Viewはロジックを持たず、ViewModelとは1対1で対応します。 Viewが読み取るコントローラーは1つだけで、描画を担います。許可されるのは表示・非表示を切り替える
if、レイアウト、アニメーション、ナビゲーションのみです。リポジトリー呼び出し、try/catchを使ったビジネスロジック、単位計算、build()内でのフォーマット処理は禁止です。ロジックはViewModelに置きます。 - ある機能から別の機能をインポートしてはいけません。 foundation レイヤー(core/data)を介して共有するか、ルート ID で遷移します。依存関係は下位にのみ向けます。機能間のインポートはlint/grepのチェックで失敗させます。横方向の結合は、2つの機能を分割できない一塊にしてしまいます。
- ストアの読み取りはリポジトリープロバイダー経由に限定し、
.watch()はスコープ付きの StreamProvider でラップします。 ウィジェットやViewModelからDrift、セキュアストレージ、プラットフォームチャネルに直接アクセスしてはいけません。ストリームはキー(所有者/親IDや時間枠など)でスコープを限定し、1つの書き込みがアプリ全体で再発行を引き起こさないようにします。 buildではref.watch、コールバックではref.read、単発の副作用(SnackBar、画面遷移)にはref.listenを使います。 コールバック内で監視すると再購読され、build内で読み取ると再ビルドが発生しません。(使い分けの詳細はstate-management-riverpodを参照してください。)- キーごとの状態には
familyとautoDispose、シングルトンにはkeepAliveを使います。 特定のエンティティ/セッションにスコープされたプロバイダーは、安定した等価比較可能な値をキーとするfamilyにし、画面とともに破棄されるようautoDisposeを設定します。キーなしの「現在のもの」プロバイダーは禁止です。アプリスコープのdb/serviceシングルトンにautoDisposeを設定してはいけません。 - すべての変更処理を1つのリポジトリーメソッドに集約し、唯一の書き込み経路とします。公開より先に永続化します。 ViewModelのコマンドは名前付きのリポジトリーメソッドを呼び出してコミットし、その後にストリームが再発行されてUIが再ビルドされます。楽観的な再発行、
setState、手動でのキャッシュ更新、「後で保存」は禁止です。 - 保存せずに導出します。 件数、合計、フィルター済みリスト、連続記録は、信頼できる唯一の情報源から(派生/ストリームプロバイダーで)計算します。同期がずれる可能性のあるカウンターを別途保存してはいけません。
- リポジトリーで行を値オブジェクトに変換し、ViewModelでは値オブジェクトを受け渡します。 Driftの行、コンパニオン、
MapをViewModelやウィジェットに渡してはいけません。計測量には値オブジェクトを使用します(value-objects-money-and-unitsを参照)。金額を単なるdoubleで表してはいけません。 - エラーは
AsyncValueとして公開する型付きの値です。 リポジトリーはsealedResult/Failureを返し、ViewModelはAsyncData/AsyncErrorに変換します。Viewは網羅的にswitchし、表示レイヤーでFailureをローカライズします。例外の生の文字列を使ってはいけません。(error-handling-typed-resultsを参照してください。) - 型付きルートを使って1つのgo_routerに登録し、識別情報はパスパラメーターで渡します。
TypedGoRoute/GoRouteDataを追加し、ディープリンク可能な識別情報はパスパラメーター(:taskId)で渡します。コールドスタート/OSによる復元時にnullになるstate.extraは使いません。全画面の追加/編集フローにはparentNavigatorKey: rootNavigatorKeyを設定します。ルーター自体(シェル/ブランチ構造、リダイレクト/認証ガード、ディープリンク、画面遷移)はnavigation-and-routingが管理します。この機能では、そのルーターにルートを登録するだけです。 - Viewまたはルーターのリダイレクトから遷移し、ViewModelからは遷移しません。 コントローラーは状態を公開し、画面に何を表示するかはViewまたは
redirectが決めます。ルート遷移を行うコントローラーはロジックをナビゲーションに結合し、ヘッドレステストを妨げます。 - 現在時刻は注入し、機能内で
DateTime.now()を呼び出してはいけません。 ViewModelはClockプロバイダーから時刻を取得するため、固定したフェイククロックを使ってフロー全体を検証できます。(async-safetyを参照してください。) - 方向に依存しないジオメトリーとローカライズ済み文字列のみを使います。
EdgeInsetsDirectional/AlignmentDirectionalを使い、.left/.right、Alignment.centerLeft、TextAlign.leftは使いません。ユーザーに表示する文字列はすべてgen-l10nから取得し、すべてのロケールARB間でキーとプレースホルダーを一致させます。(i18n-rtl-l10nを参照してください。) - ViewModelのテストではNotifierではなくリポジトリーをフェイクにします。
ProviderContainer.test()とoverrideWithでフェイクリポジトリーを設定し、clockProvider.overrideWithValue(Clock.fixed(t))を指定します。ウィジェットのpumpは行わず、配線とUI状態へのマッピングのみを検証します。(testing-strategyを参照してください。)
標準的な Notifier +スコープ付きストリーム
デフォルトでは手書きのプロバイダーを使用します(state-management-riverpod がこの規律を担います。@riverpod
コード生成は任意の代替手段であり、デフォルトではありません)。スコープ付きストリームとコマンド用
Notifier は、<feature>_providers.dart/<feature>_notifier.dart にまとめて配置します。ファイル全体:
examples/tasks_notifier.dart。
// features/tasks/presentation/tasks_providers.dart — reactive backbone, scoped so one write is not app-wide.
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:app/core/result.dart'; // Result, Ok, Err, Failure (shared foundation, lib/core/)
import 'package:app/data/task_repository.dart'; // taskRepositoryProvider (keepAlive; lives in lib/data/)
import '../domain/task.dart'; // feature-local immutable model (value objects, not rows)
/// Wrap the repository's scoped `.watch()` in a family StreamProvider, keyed by projectId.
/// `.autoDispose` frees the subscription when the screen unmounts. The repository maps rows ->
/// domain models at the boundary; this never sees a Drift row.
final taskListProvider =
StreamProvider.autoDispose.family<List<Task>, String>((ref, projectId) =>
ref.watch(taskRepositoryProvider).watchTasks(projectId));
// features/tasks/presentation/tasks_notifier.dart — the 1:1 ViewModel: command state + writes.
final tasksNotifierProvider =
AsyncNotifierProvider.autoDispose<TasksNotifier, void>(TasksNotifier.new);
class TasksNotifier extends AsyncNotifier<void> {
@override
FutureOr<void> build() {} // no synchronous heavy work here
/// A command the View binds. The write goes through ONE repository method (single write path);
/// the typed Result is mapped to AsyncValue for the View to switch on exhaustively.
Future<void> add(Task draft) async {
state = const AsyncLoading();
final Result<void, Failure> result =
await ref.read(taskRepositoryProvider).addTask(draft);
state = switch (result) {
Ok() => const AsyncData(null),
Err(:final failure) => AsyncError(failure, StackTrace.current),
};
// No manual republish: the committed write makes the Drift stream re-emit and the list rebuilds.
}
}
シンプルな View
View はスコープ付きストリームを監視し、.when の中で描画して、コールバックから
ref.read(...notifier) を呼び出します。単発の副作用には、コントローラーに対して ref.listen を使います。ファイル全体:examples/tasks_screen.dart。
class TasksScreen extends ConsumerWidget {
const TasksScreen({required this.projectId, super.key});
final String projectId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
ref.listen(tasksNotifierProvider, (prev, next) { // one-shot effects only
if (next case AsyncError(:final error)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(localizeFailure(l10n, error))), // typed Failure -> localized text
);
}
});
final async = ref.watch(taskListProvider(projectId)); // watch in build
return Scaffold(
appBar: AppBar(title: Text(l10n.tasksTitle)),
body: async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => FailureView(message: localizeFailure(l10n, e)),
data: (tasks) => tasks.isEmpty
? EmptyState(title: l10n.tasksEmptyTitle)
: ListView.builder(
padding: const EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 8),
itemCount: tasks.length,
itemBuilder: (context, i) => TaskTile(
task: tasks[i],
onTap: () => TaskDetailRoute( // navigate by ID, from the View
projectId: projectId,
taskId: tasks[i].id,
).go(context),
),
),
),
);
}
}
ジェネレーターでは実行できない、手動で行う 2 つの手順
- 単一の go_router にルートを登録し(
TypedGoRoute、パスパラメーターによる ID、 全画面フローではparentNavigatorKey: rootNavigatorKey)、その後 build_runner で型付きルートを再生成します。references/routing-and-l10n.mdを参照してください。 - まずテンプレートの ARB に文字列を追加し、すべてのロケールの ARB に同じプレースホルダー名と ICU 構造で反映します。整合性チェックを実行し、次にコード生成を行い、その後解析します。
オプション:機能が新しい永続化レコードを読み取る場合
画面がまだ存在しないレコードを必要とする場合は、機能を接続する前に次の一連の処理を追加します:
不変の値型(純粋で、ストアをインポートしない)→ スキーマ不変条件を備えた Drift テーブル →
行を値型にマッピングする DAO → 1 つのトランザクションを開くリポジトリメソッド。その後、機能は通常どおり、スコープ付きの .watch() ストリームを読み取ります。詳しい手順は persistence-drift と run-migration に記載されています。簡潔な案内については references/add-persisted-record.md を参照してください。
アンチパターン
- 画面しか必要ないのに新しいパッケージを作る。 画面はフォルダーであり、パッケージは境界に関する判断です。
- 1 つのファイルに主要な型を 2 つ置くこと、Riverpod の場合に ViewModel を
<feature>_view_model.dart/*Controllerと命名すること、または規約で_screen.dartを使うところで View を<feature>_view.dartと命名すること。 ファイルごとに主要な公開型を 1 つだけ置き、ファイル名はその宣言名にします(naming-conventions)。Riverpod の ViewModel は<feature>_notifier.dart→<Feature>Notifierです。 build()内でリポジトリ/エンジンを呼び出すことや、try/catchによるビジネスロジック。 View は 1 つのコントローラーを読み取って描画し、ロジックは ViewModel に置きます。- ウィジェットや ViewModel から Drift/secure-storage/プラットフォームチャネルにアクセスすること。 読み取りにはリポジトリプロバイダーを使用します。行の形状はデータレイヤーの内部情報です。
- コミット前に楽観的に再公開すること、
setState、手動でのキャッシュ更新、または「後で保存」するデバウンス処理。 すべての書き込みは、永続化してからストリームを再送出する 1 つのリポジトリメソッドで行います。 - 保存済みのカウンターを正しい値として扱うこと。 読み取り時に、件数/合計/連続記録を信頼できる情報源から算出します。
- キーのない「現在のエンティティ」プロバイダー、またはアプリスコープのシングルトンに
autoDisposeを指定すること。 キーごとの状態にはfamily+autoDisposeを使い、シングルトンにはkeepAliveを使います。 - 機能内で
DateTime.now()を使うこと。 フローをアサートできるよう、注入されたClockプロバイダーから読み取ります。 - ディープリンク可能な ID に
state.extraを使うこと、または主要なフローでNavigator.pushを使うこと。 パスパラメーターと型付きのGoRouteを使います。ナビゲーションを決めるのは View/リダイレクトであり、コントローラーではありません。 - 別の機能のフォルダーをインポートすること。 依存先は下位に限り、横方向には依存しません。
- 左/右の位置をハードコードすること、または UI のリテラルを直接記述すること。
EdgeInsetsDirectionalと gen-l10n の文字列を使います。 - リポジトリを偽装せず、テストで
Notifierをモックすること。 接合部を偽装し、配線をテストします。
完了の定義
- 機能は
lib/features/<feature>/フォルダーであり、presentation/配下に固定の構成を備えています。<feature>_screen.dart(ロジックを持たない View)、<feature>_notifier.dart(1:1 の Notifier/AsyncNotifier)、widgets/、スコープ付きの<feature>_providers.dartを配置します。 - View はコントローラーをちょうど 1 つ読み取り、描画します。
build()内でリポジトリーを呼び出したり、ビジネスロジックに関するtry/catchを使ったりしません。.whenで穏やかなエラーを表示します。 - 依存関係は下位にのみ向けます。他の機能のフォルダーをインポートしません。機能内では Drift/DAO/セキュアストレージ/プラットフォームチャネルをインポートしません。
- 読み取りには、リポジトリーの
.watch()を使うスコープ付き StreamProvider を使用します。キーごとのプロバイダーは安定した値をキーとするfamilyとし、autoDisposeにします。シングルトンにはkeepAliveを使用します。 - すべての変更処理は、名前付きのリポジトリーメソッド 1 つ(書き込み経路は 1 つ)を経由し、再公開する前にコミットします。派生状態は読み取り時に計算し、保存しません。
- ViewModel は値オブジェクトを渡します。Drift の行/コンパニオン/
Mapをウィジェットに渡しません。金額に単なるdoubleを使いません。 - エラーは sealed
Result/Failureとし、AsyncValueにマッピングします。View はすべてのケースを網羅して切り替え、境界で Failure をローカライズします。 - 「現在時刻」は注入された
Clockから取得します。機能内でDateTime.now()を使用しません。 - 型付き
GoRouteを単一のルーターに登録します。識別情報はパスパラメーターに含めます。全画面フローではparentNavigatorKeyを設定し、ViewModel からはナビゲーションしません。 - 文字列には gen-l10n を使用し、テンプレートを先に作成してからパリティを確認します。ジオメトリには
Directionalのみを使用します。 - ViewModel は
ProviderContainer.test()とフェイクのリポジトリー、固定のClock(モックの Notifier ではなく、clockProvider.overrideWithValue(Clock.fixed(t)))でテストします。 -
dart formatとdart analyze --fatal-infosがクリーンであり、scripts/verify_feature.shが成功します。
関連スキル
flutter-architecture— このスキャフォールドが従うレイヤーモデルと、下位方向のみの DAG。state-management-riverpod— Notifier/AsyncNotifier の規律、watch/read/listen、family+autoDispose、手動プロバイダー優先の方針。navigation-and-routing— この機能が型付きルートを登録する、単一の go_router を管理します。forms-and-input— この機能が起動する追加/編集フローの Form/バリデーション/フォーカスの規律。widget-composition— ロジックを持たない View、小さな const ウィジェットクラス、構造的なレイアウト。error-handling-typed-results—AsyncValueとして公開される sealedResult/Failureの基盤。persistence-driftとrun-migration— 新しいレコードを追加する際の、リポジトリー/DAO/テーブル/マイグレーションの手順。i18n-rtl-l10n— ARB のパリティと Directional ジオメトリの契約。value-objects-money-and-units— ViewModel が渡す標準の値オブジェクト。testing-strategy— ViewModel をヘッドレスでテストするための、リポジトリー(およびclockProvider)のフェイク化。project-structure-and-packages— 単一パッケージのツリーを管理します。基盤がフォルダーではなくパッケージとして成立する場合に使用します。
参考資料
- Flutter — アプリのアーキテクチャガイド:https://docs.flutter.dev/app-architecture
- Riverpod 3.x:https://riverpod.dev
- go_router — 型付きルート:https://pub.dev/documentation/go_router/latest/topics/Type-safe%20routes-topic.html
- Flutter — 国際化(gen-l10n):https://docs.flutter.dev/ui/accessibility-and-internationalization/internationalization
Provider/ChangeNotifier 付録
公式の Flutter ガイドのスタック(package:provider + ChangeNotifier)を使用していますか?ルールは同じで、配線だけが変わります。
- ViewModel は
ChangeNotifierです。インテントメソッドはプライベートフィールドを変更し、最後に一度だけnotifyListeners()を呼び出します(引き続き、呼び出しごとに不変な状態スナップショット 1 つ分の変更です)。 - 機能のルートで
ChangeNotifierProviderを使って提供します。View はbuild内でcontext.watch<TasksViewModel>()、コールバック内でcontext.read<TasksViewModel>()を使って読み取ります。 Riverpod と同じく、build 内では watch、コールバック内では read を使い分けます。 - リアクティブな読み取りには、リポジトリの
Streamを公開し、StreamProvider(providerパッケージのStreamProviderまたはStreamBuilder)で畳み込みます。ViewModel 内に変更可能なコピーを キャッシュしてはいけません。emit のたびに導出します。 - 画面ごとのスコープは
family+autoDisposeの代わりになります。画面でChangeNotifierProviderを作成して ルートとともに破棄し、アプリスコープのサービスはルートプロバイダーに保持します。 - コントローラーやサブスクリプションは
ChangeNotifier.dispose()で破棄します。
それ以外はすべて変わりません。つまり、シンプルな View、単一の書き込み経路、型付きの Result/Failure、
パスパラメーターによるルーティング、注入されたクロック、ARB の同等性、Directional ジオメトリ、
テストでのリポジトリのフェイク化はそのままです。