Flutterフレームワークの最大の強みは、AIツールと組み合わせるときに最大のリスクにもなりえます。大量のコードを生成するその性質上、明確なルールがなければClaude Codeは構文的に正しいDartコードを出力しつつも、アーキテクチャを壊し、状態管理パターンを無視し、必要なのに間違ったテストを書いてしまいます。
このガイドでは、Flutter プロジェクト向けの完全な CLAUDE.md テンプレートを提供します。クリーンアーキテクチャ、Riverpod、プラットフォーム固有ルール、テスト規約を網羅した実践的な内容です。実際のプロジェクトからFlutter固有のルールを ギャラリー でも参照できます。
Claude Code + Flutter の相性が良い理由
Flutterプロジェクトはボイラープレートと実際のロジックの比率が高く、そこがClaude Codeの得意領域です。StatefulWidgetは特定のクラス階層を要求し、Riverpodプロバイダーは厳格な宣言パターンに従い、クリーンアーキテクチャは1つの機能を4つのディレクトリに分散させます。これらは知的に難しいわけではなく、ただタイプするのが面倒なだけです。
Claude Codeはこのボイラープレート生成を非常にうまく処理しますが、プロジェクト固有の規約を理解している場合に限ります:
- Riverpodパターン: プロバイダーの定義方法は複数あります。ルールなしでは、
Provider、StateNotifierProvider、AsyncNotifierProviderが同一コードベースに混在します - ウィジェット分解: コンテキストによってウィジェットの粒度が変わります。ルールで一貫性を保ちます
- インポート整理: Flutterプロジェクトでは
package:インポート、相対インポート、バレルファイルが混在しがちです - フィーチャーvsレイヤー構造: クリーンアーキテクチャはレイヤー別(data/domain/presentation)でもフィーチャー別でも整理できます。どちらも有効ですが、混在は避けます
Flutter プロジェクト向け完全 CLAUDE.md テンプレート
プロダクション対応のテンプレートです。プロジェクトルートに CLAUDE.md としてコピーし、スタックに合わせて調整してください。
# Flutter プロジェクト — AI コーディングルール
## プロジェクト概要
- Flutter 3.x / Dart 3.x
- 状態管理: Riverpod(flutter_riverpod + riverpod_annotation)
- アーキテクチャ: クリーンアーキテクチャ、フィーチャーファースト構造
- ルーティング: go_router
- 依存性注入: Riverpod(専用DIフレームワークは使用しない)
- HTTP: Dio + Retrofit(コード生成APIクライアント)
- ローカルストレージ: Hive(構造化データ)/ shared_preferences(シンプルなKV)
- コード生成: build_runner(freezed、riverpod_generator、retrofit、json_serializable)
## Dartバージョンとスタイル
- Dart 3.x、null安全(デフォルト非null)
- sound null safetyパターンを使用する
- `late`は本当に必要な場合のみ — 明示的な初期化を優先する
- 値が変わらない全宣言に`final`を使用する
- `const`コンストラクタを可能な限り使用する
- 3つ以上のパラメータには名前付きパラメータを使用する
- 行長: 80文字(dart formatデフォルト)
- `dynamic`は使用しない — ジェネリック型または具体的な型を使用する
## プロジェクト構造(フィーチャーファースト・クリーンアーキテクチャ)
lib/
├── core/ # 共有インフラ
│ ├── constants/ # アプリ全体の定数
│ ├── errors/ # Failureタイプ、例外
│ ├── extensions/ # Dart拡張メソッド
│ ├── network/ # Dioセットアップ、インターセプター、トークンリフレッシュ
│ ├── router/ # go_router設定
│ ├── theme/ # ThemeData、テキストスタイル、カラー
│ └── utils/ # 純粋なユーティリティ関数
├── features/
│ └── [feature_name]/
│ ├── data/
│ │ ├── datasources/ # リモート・ローカルデータソース
│ │ ├── models/ # APIレスポンスモデル(Freezed + JSON)
│ │ └── repositories/ # リポジトリ実装
│ ├── domain/
│ │ ├── entities/ # 純粋なDartビジネスオブジェクト
│ │ ├── repositories/ # 抽象リポジトリインターフェース
│ │ └── usecases/ # 単一責任ユースケース
│ └── presentation/
│ ├── pages/ # フルスクリーンウィジェット(ルートターゲット)
│ ├── widgets/ # フィーチャー固有の再利用可能ウィジェット
│ └── providers/ # このフィーチャーのRiverpodプロバイダー
└── shared/
└── widgets/ # 本当にフィーチャー横断のウィジェット
## 命名規則
### ファイル
- 全ファイル名はsnake_case: `user_profile_page.dart`
- サフィックス規則:
- ページ: `_page.dart`
- ウィジェット: `_widget.dart`(曖昧さ回避が必要な場合のみ。それ以外は`_card.dart`、`_tile.dart`等)
- プロバイダー: `_provider.dart`
- ユースケース: `_usecase.dart`
- リポジトリ(インターフェース): `_repository.dart`
- リポジトリ(実装): `_repository_impl.dart`
- モデル: `_model.dart`
- エンティティ: `_entity.dart`(または単純名詞: `user.dart`)
### クラス
- 全クラス名はPascalCase
- クラス名にパッケージ名を繰り返さない
### メソッドと変数
- メソッドとローカル変数はcamelCase
- プライベートメンバー: `_`プレフィックス
- 定数: lowerCamelCase(DartではSCREAMING_SNAKE_CASEを使わない)
## 状態管理(Riverpod)
### 必ずコード生成を使用する
`riverpod_annotation`と`riverpod_generator`を使用する。`Provider`、`StateNotifierProvider`、`FutureProvider`を手動で書かない。
```dart
// 良い — 生成プロバイダー
@riverpod
Future<List<User>> userList(UserListRef ref) async {
final repo = ref.watch(userRepositoryProvider);
return repo.getAll();
}
// 良い — 生成Notifier
@riverpod
class UserNotifier extends _$UserNotifier {
@override
Future<User?> build(String userId) async {
return ref.watch(userRepositoryProvider).getById(userId);
}
Future<void> updateName(String name) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(userRepositoryProvider).updateName(id: userId, name: name),
);
}
}
プロバイダーの配置
- フィーチャープロバイダーは
features/[feature]/presentation/providers/に置く - 共有/インフラプロバイダーは
core/に置く - ウィジェットファイル内にプロバイダーを置かない
AsyncValue の処理
3つの状態を常に明示的に処理する:
// 良い
child: userAsync.when(
data: (user) => UserCard(user: user),
loading: () => const CircularProgressIndicator(),
error: (e, _) => ErrorWidget(message: e.toString()),
),
// 悪い — エラー状態をスキップ
child: userAsync.maybeWhen(
data: (user) => UserCard(user: user),
orElse: () => const CircularProgressIndicator(),
),
ref.watch と ref.read の使い分け
ref.watch:build()内部 — 値が変わるとウィジェット/プロバイダーが再ビルドされるref.read: コールバックとイベントハンドラ内 — 再ビルドをトリガーしないbuild()内でリアクティブな値にref.readを使わない
ウィジェットアーキテクチャ
StatelessWidget を優先する
StatefulWidgetよりStatelessWidget + ConsumerWidget(Riverpod)を優先する。StatefulWidgetの使用は以下の場合のみ:
- ローカルの短命な状態(アニメーションコントローラー、テキストコントローラー、フォーカスノード)
- ライフサイクルフック(initState、dispose)
- プロバイダーに属さない状態
ウィジェット分解
以下の場合にウィジェットを抽出する:
constコンストラクタの恩恵を受けられる場合- 2箇所以上で再利用される場合
- 独自のRiverpod依存関係を持つ場合
- buildコードが約50行を超える場合
行数のためだけにマイクロウィジェットを抽出しない。意味のある分解を優先する。
const コンストラクタ
以下にconstを追加する:
- 動的データのない全ウィジェットコンストラクタ
- 静的な
Text、Icon、SizedBox、Paddingインスタンス - 固定構造を返す全buildメソッド
Context 拡張メソッド
頻繁なtheme/MediaQuery アクセスには拡張メソッドを作成する:
extension BuildContextX on BuildContext {
ThemeData get theme => Theme.of(this);
TextTheme get textTheme => Theme.of(this).textTheme;
ColorScheme get colorScheme => Theme.of(this).colorScheme;
double get screenWidth => MediaQuery.sizeOf(this).width;
double get screenHeight => MediaQuery.sizeOf(this).height;
}
データ層
モデル(Freezed + JSON)
全APIレスポンスモデルはJSON シリアライズ付きFreezeを使用する:
@freezed
class UserModel with _$UserModel {
const factory UserModel({
required String id,
required String name,
required String email,
@JsonKey(name: 'created_at') required DateTime createdAt,
}) = _UserModel;
factory UserModel.fromJson(Map<String, dynamic> json) =>
_$UserModelFromJson(json);
}
エンティティ
ドメインエンティティはJSONなしの純粋なDartクラスまたはFreezeクラスにする:
@freezed
class User with _$User {
const factory User({
required String id,
required String name,
required String email,
required DateTime createdAt,
}) = _User;
}
リポジトリパターン
ドメインで抽象インターフェースを定義し、データ層で実装する:
// domain/repositories/user_repository.dart
abstract class UserRepository {
Future<List<User>> getAll();
Future<User> getById(String id);
Future<User> updateName({required String id, required String name});
}
// data/repositories/user_repository_impl.dart
class UserRepositoryImpl implements UserRepository {
final UserRemoteDataSource _remote;
final UserLocalDataSource _local;
const UserRepositoryImpl({
required UserRemoteDataSource remote,
required UserLocalDataSource local,
}) : _remote = remote, _local = local;
@override
Future<List<User>> getAll() async {
final models = await _remote.fetchAll();
return models.map((m) => m.toEntity()).toList();
}
// ...
}
エラーハンドリング
生の例外ではなくsealedのFailureクラスを使用する:
sealed class Failure {
const Failure();
}
class NetworkFailure extends Failure {
final int? statusCode;
const NetworkFailure({this.statusCode});
}
class CacheFailure extends Failure {
const CacheFailure();
}
class ValidationFailure extends Failure {
final String field;
final String message;
const ValidationFailure({required this.field, required this.message});
}
ユースケースで例外的でない期待されるエラーを返す場合は、fpdartのEither<Failure, T>を使用する。
ルーティング(go_router)
ルート定数
ウィジェット内でルート文字列をハードコードしない:
// core/router/routes.dart
abstract class Routes {
static const home = '/';
static const userProfile = '/users/:id';
static const settings = '/settings';
}
TypedRoute パターン
型安全なナビゲーションのために可能な限りTypedGoRouteアノテーションを使用する。生成されたルートクラスによりパス文字列のバグを排除できる。
コード生成
コード生成を使用するファイルを変更した後は、以下の実行をユーザーに必ず伝える:
dart run build_runner build --delete-conflicting-outputs
build_runnerが必要なファイル:
- Freezedモデル(
@freezed) - Riverpodプロバイダー(
@riverpod) - Retrofit APIクライアント(
@RestApi) - JSON serializable(
@JsonSerializable) - go_router typed routes(
@TypedGoRoute)
.g.dartや.freezed.dartファイルを手動で編集しない。
インポート
順序(dart formatで強制)
- Dart SDKインポート(
dart:) - Flutterインポート(
package:flutter/) - 外部パッケージインポート(
package:) - 相対インポート(
../、./)
バレルファイル
バレルファイル(index.dart)はレイヤーのパブリックAPIのみに使用する:
// features/user/domain/domain.dart
export 'entities/user.dart';
export 'repositories/user_repository.dart';
export 'usecases/get_user_usecase.dart';
全てを単純に再エクスポートするバレルファイルを作成しない — 明示的に記述する。
プラットフォーム固有ルール
アダプティブウィジェット
プラットフォームセマンティクスを持つコンポーネントにはアダプティブコンストラクタを優先する:
Switchの代わりにSwitch.adaptiveCircularProgressIndicatorの代わりにCircularProgressIndicator.adaptiveAlertDialogの代わりにAlertDialog.adaptive
プラットフォーム検出
Flutterウィジェットコードでは(Platform.isIOSではなく)defaultTargetPlatformを使用する:
import 'package:flutter/foundation.dart';
final isIOS = defaultTargetPlatform == TargetPlatform.iOS;
Platform.isIOSはウィジェット以外のコード(サービス、ネイティブ呼び出し)のみで使用する。
セーフエリア
ルートページコンテンツは必ずSafeAreaでラップする。ノッチやホームインジケーターのためにパディング値をハードコードしない。
キーボード回避
フォームのあるページにはresizeToAvoidBottomInset: true(Scaffoldのデフォルト)を使用する。キーボード回避のための下部パディングをハードコードしない。
テストルール
ウィジェットテスト(単位: ウィジェット)
- ウィジェットファイルごとに1つのテストファイル
- テスト対象: 必須プロパティでのレンダリング、ユーザー操作、状態変化
- 後続の再ビルドには
pumpWidget()でなくpump()を使用する ProviderScopeオーバーライドでRiverpodプロバイダーをモック化する
testWidgets('UserCardが名前とメールを表示する', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
userProvider.overrideWith((ref) => fakeUser),
],
child: const MaterialApp(home: UserCard(userId: 'test')),
),
);
expect(find.text('Alice'), findsOneWidget);
expect(find.text('[email protected]'), findsOneWidget);
});
インテグレーションテスト(単位: フィーチャーフロー)
- 場所:
integration_test/ IntegrationTestWidgetsFlutterBinding付きのintegration_testパッケージを使用する- 完全なユーザーフローをテスト: ログイン → 一覧 → 詳細 → アクション
- リポジトリレベルではなくネットワーク層でモック化する(Dioインターセプター)
ユニットテスト(単位: クラス)
- ユースケース、リポジトリ、ユーティリティ関数をテストする
- ユニットテストにFlutter依存関係を含めない
- ウィジェットをテストする場合のみ
flutter_testを使用する
テストしないもの
- プライベートメソッド
- ロジックのない単純なゲッター
- 生成されたコード(
.g.dart、.freezed.dart) - Flutterフレームワーク内部(
setStateが呼ばれたことをテストしない)
よくある落とし穴とルールによる防止策
落とし穴1: 状態管理パターンの混在
ルールがないと、Claude Codeはその場のコンテキストに合ったパターンを使用します。複数のセッションにわたると、setState、ChangeNotifier、Bloc、Riverpodが混在したコードベースになります。解決策は明示的な禁止です:
## 状態管理ポリシー
このプロジェクトはアプリケーション状態に ONLY Riverpod を使用する。
以下を導入しない:
- setState(ウィジェットローカルの短命な状態以外)
- ChangeNotifier / ValueNotifier(アニメーションコントローラー以外)
- Bloc / Cubit
- Providerパッケージ(pub.devのパッケージ — Riverpodを使用する)
- GetX / MobX / Redux
上記が必要と思う場合は、実装前に確認する。
落とし穴2: ナビゲーションの不一致
go_routerと命令型ナビゲーション(Navigator.push)は異なるバックスタック動作を生みます。ルールなしでは混在します:
## ナビゲーションポリシー
- ナビゲーションには ONLY go_router を使用する(context.go、context.push、context.pop)
- `Navigator.of(context).push/pop`を直接使わない
- `MaterialPageRoute`を直接使わない
- 全ルートを core/router/app_router.dart で定義する
落とし穴3: 非同期ギャップ後の BuildContext
これは静的解析が常には検出しないランタイムエラーです。Claude Codeはフォーム送信ハンドラーで頻繁に生成します:
## 非同期BuildContextルール
`await`の後は、`context`を使用する前に`mounted`を確認する:
```dart
Future<void> handleSubmit() async {
await service.doSomething();
if (!mounted) return; // 必須
context.pop();
}
これはStatefulWidgetのメソッドと、BuildContextを使用するコールバックの両方に適用される。
### 落とし穴4: コード生成の忘れ
Claude Codeは時にFreezeやRiverpodファイルを変更し、build_runnerの実行を言及せずに完了として提示します:
```markdown
## コード生成リマインダー
@freezed、@riverpod、@RestApi、または@JsonSerializableアノテーションを含むファイルへの
変更の後は、必ず以下を出力する:
「build_runnerで再生成を実行してください: dart run build_runner build --delete-conflicting-outputs」
このリマインダーなしに変更を完了として提示しない。
落とし穴5: late変数の乱用
lateは初期化後の非null変数に有効ですが、Claude Codeは初期化の順序を考えることを避けるための近道として乱用します:
## late変数ポリシー
`late`は以下の場合のみ使用する:
1. 最初の使用前に確実に初期化される変数(コンパイラが証明できない場合)
2. 初期化に`this`アクセスが必要な場合
null安全性を避けるために`late`を使わない。代わりに:
- `late final T`の代わりにNullable型 `?`
- 初期化式
- 遅延評価パターン
プロバイダーライフサイクルの詳細
RiverpodプロジェクトでのClaude Codeの最も一般的なミスの一つ: build()内で別のプロバイダーをref.watchではなくref.readで読み取るプロバイダーを生成することです。これは最初のロードでは動作しますが、依存関係が変化しても更新されません:
// 悪い — build()でref.readを使用
@riverpod
Future<UserProfile> userProfile(UserProfileRef ref) async {
final userId = ref.read(authProvider).userId; // 認証変更に反応しない
return ref.read(userRepositoryProvider).getProfile(userId);
}
// 良い — build()でref.watchを使用
@riverpod
Future<UserProfile> userProfile(UserProfileRef ref) async {
final userId = ref.watch(authProvider).userId; // リアクティブ
final repo = ref.watch(userRepositoryProvider);
return repo.getProfile(userId);
}
このパターンを明示的に言及したルールをCLAUDE.mdに追加することで防止できます。
テンプレートの活用方法
上記テンプレートをコピーして、スタックに合わせて以下のセクションを調整します:
- プロジェクト概要:
pubspec.yamlに合わせてパッケージを更新する - プロジェクト構造: フィーチャー名とアーキテクチャの変形に合わせて調整する
- 状態管理: 使用しない状態管理のセクションを削除する
- テスト: テストパッケージ名と場所を調整する
- プラットフォームルール: ターゲットにしないプラットフォームのセクションを削除する
最も重要なセクションは命名規則、Riverpodのref.watch/ref.readのルール、そして一般的な落とし穴です。これらは明示的なガイダンスなしでClaude Codeが最も一貫性のない出力を生成する領域です。
実際のプロジェクトからのFlutter CLAUDE.md例は ギャラリー で参照できます。また、CLAUDE.md効果的な書き方ガイドやClaude Codeベストプラクティスもご覧ください。