
Flutter開発における小・中規模プロジェクトのベストプラクティス
小〜中規模のFlutter開発プロジェクトにおける現時点で推奨されるベストプラクティスをまとめてみた

はじめに
Flutterによるモバイルアプリ開発において、プロジェクト立ち上げ時のアーキテクチャ設計は、その後の開発効率や保守性に大きな影響を与えます。
適切な設計なしに開発を進めると、機能追加の度にコードが複雑化し、バグの原因特定が困難になり、テストが書きにくいといった問題に直面することになります。
この記事では、小規模から中規模のFlutterプロジェクト新規立ち上げ時に採用すべきアーキテクチャパターン、技術スタック、設計指針について、実践的な例を交えて解説します。
特に以下の3つのポイントを中心に、実装例とともに詳しく紹介していきます。
- MVVM + Repositoryパターン
- UIロジックとビジネスロジックを明確に分離し、保守性の高いコード構造を実現
- Riverpod + Hooks + Freezedの技術スタック
- 型安全性と開発効率を両立させる現代的なFlutter開発の定番構成
- プロジェクト新規立ち上げ時の設計指針
- 最初から適切な構造で始めるためのチェックリストと心得
これらの知識を身につけることで、スケーラブルで保守性の高いFlutterアプリケーションを構築できるようになるでしょう。
MVVM + Repositoryパターンで実現する責務の明確な分離
アーキテクチャパターンの概要と特徴
小規模から中規模のFlutterプロジェクトでは、MVVM (Model-View-ViewModel) + Repository パターンが最も推奨されるアーキテクチャです。
このパターンは、関心の分離という基本原則に基づいており、各レイヤーが明確な責務を持つことで、開発効率とコードの品質を大幅に向上させます。
アーキテクチャの全体像は以下のとおりです。

- View (UI層): ユーザーインターフェースの表示を担当
HookConsumerWidgetを使用してViewModelの状態を監視し、
ユーザーの操作をViewModelに委譲します。
- ViewModel (UI層): ビジネスロジックと状態管理を担当
StateNotifierを継承して実装し
Repositoryからデータを取得してStateを更新します。
- Repository (データ層): データソースへのアクセスを抽象化
- APIクライアントやデータベースとの通信を担当し、
エラーハンドリングも行います。
- APIクライアントやデータベースとの通信を担当し、
- Service(API) (データ層): 外部APIアクセスを担当
- 外部Web APIへの具体的なHTTPリクエストの実行を担当し、
エンドポイントに対してデータの取得や送信などを行います。
- 外部Web APIへの具体的なHTTPリクエストの実行を担当し、
- Model: ドメインオブジェクト
freezedによるイミュータブルなデータクラスとして定義します。
このアーキテクチャを採用することで得られる具体的なメリットは以下のとおりです。
1. テストが容易になる
各レイヤーが独立しているため、モックを使った単体テストが簡単に書けます。
例えば、ViewModelのテストではRepositoryをモック化することで、APIの状態に依存せずロジックを検証できます。
2. コードの再利用性が向上
Repositoryを共通化することで、同じデータソースを複数の画面で利用できます。
また、ViewModelも再利用可能な単位で設計できます。
3. 変更の影響範囲が最小化
UIの変更はViewのみ、API仕様の変更はRepositoryのみに影響を限定できます。
レイヤー間の依存が一方向なので、変更時のリスクが低減されます。
4. チーム開発がスムーズ
役割分担が明確なので、UIデザイナー、フロントエンドエンジニア、バックエンドエンジニアがそれぞれ独立して作業を進められます。
プロジェクト立ち上げ時に以下のアクションを実施することで、このアーキテクチャを効果的に導入できます。
例えばプロジェクト開始時に以下のようなディレクトリ構造を作成します。
lib/
├── model/ # ドメインモデル
│ ├── user.dart
│ ├── user_list_response.dart
│ └── ...
│
├── api/ # APIクライアント
│ └── user_api_client.dart # Retrofit API Client
│
├── repository/ # Repository層
│ └── user_repository.dart
│
├── page/ # 画面UI
│ ├── user_list_page/
│ │ ├── user_list_page.dart # View
│ │ └── component/
│ │ ├── user_list_viewmodel.dart # ViewModel
│ │ └── user_list_state.dart # State
│ └── user_detail_page/
│ └── ...
│
├── provider/ # Riverpod Provider定義
│ ├── dio_provider.dart
│ ├── user_api_client_provider.dart
│ └── user_repository_provider.dart
│
└── util/ # ユーティリティ
├── logger.dart
└── router.dart # go_router
Riverpodを使用して、各レイヤーの依存関係を明確に定義します。
// API ClientのProvider
final userApiClientProvider = Provider<UserApiClient>((ref) {
final dio = ref.watch(dioProvider);
return UserApiClient(dio);
});
// RepositoryのProvider
final userRepositoryProvider = Provider<UserRepository>((ref) {
final apiClient = ref.watch(userApiClientProvider);
return UserRepository(apiClient);
});
// ViewModelのProvider
final userListViewModelProvider =
StateNotifierProvider<UserListViewModel, UserListState>((ref) {
final repository = ref.watch(userRepositoryProvider);
return UserListViewModel(repository);
});
以下の順序で実装を進めることで、効率的に開発できます。
- Modelの定義:
freezedを使ってデータクラスを作成 - APIクライアントの実装:
retrofitでAPI通信層を実装 - Repositoryの実装: APIクライアントを使ったデータ取得処理
- Stateの定義: ViewModelが管理する状態を定義
- ViewModelの実装: ビジネスロジックと状態管理
- Viewの実装: UIの表示とユーザー操作の受付
この順序で実装することで、下位レイヤーから順に構築でき、各レイヤーの動作確認をしながら進められます。
Riverpod + Hooks + Freezedで実現する型安全で効率的な開発
技術スタックの概要と特徴
現代のFlutter開発において、Riverpod + Hooks + Freezedの組み合わせは事実上のスタンダードとなっています。
この技術スタックは、【型安全性】【開発効率】【コードの簡潔さ】の3つを高いレベルで実現します。
Riverpod (hooks_riverpod)
- 状態管理とDI(依存性注入)を一元的に管理
- グローバルに状態を共有しつつ、テスタビリティを確保
Provider、StateNotifierProvider、FutureProviderなど、用途に応じた複数の種類を提供
Flutter Hooks (flutter_hooks)
- 【ライフサイクル管理】【ローカル状態】【副作用】を簡潔に記述
useState、useEffect、useMemoizedなど、Reactライクな記法HookConsumerWidgetでRiverpodと統合
Freezed
- イミュータブルなデータクラを自動生成
copyWith、==、hashCode、JSONシリアライゼーションを自動実装- Union typesによる型安全な状態表現
実装例を見ていきましょう。
Modelの定義 (Freezed)
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
class User with _$User {
const factory User({
required int id,
required String name,
required String email,
String? avatarUrl,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) =>
_$UserFromJson(json);
}
Stateの定義 (Freezed)
@freezed
class UserListState with _$UserListState {
const factory UserListState({
@Default([]) List<User> users,
@Default(false) bool isLoading,
@Default(true) bool hasMore,
String? error,
}) = _UserListState;
}
ViewModelの実装 (Riverpod + StateNotifier)
class UserListViewModel extends StateNotifier<UserListState> {
UserListViewModel(this._repository) : super(const UserListState()) {
fetchUserList();
}
final UserRepository _repository;
Future<void> fetchUserList() async {
state = state.copyWith(isLoading: true, error: null);
try {
final response = await _repository.fetchUserList();
state = state.copyWith(
users: response.results,
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
Future<void> refresh() async {
state = const UserListState();
await fetchUserList();
}
}
Viewの実装 (HookConsumerWidget)
class UserListPage extends HookConsumerWidget {
const UserListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final viewModel = ref.watch(userListViewModelProvider.notifier);
final state = ref.watch(userListViewModelProvider);
// ローカル状態の管理にHooksを使用
final scrollController = useScrollController();
useEffect(() {
// スクロール監視などの副作用
void listener() {
if (scrollController.position.pixels ==
scrollController.position.maxScrollExtent) {
viewModel.fetchUserList();
}
}
scrollController.addListener(listener);
return () => scrollController.removeListener(listener);
}, [scrollController]);
if (state.isLoading && state.users.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return Scaffold(
appBar: AppBar(title: const Text('ユーザー一覧')),
body: RefreshIndicator(
onRefresh: viewModel.refresh,
child: ListView.builder(
controller: scrollController,
itemCount: state.users.length,
itemBuilder: (context, index) {
final user = state.users[index];
return ListTile(
leading: user.avatarUrl != null
? CircleAvatar(backgroundImage: NetworkImage(user.avatarUrl!))
: const CircleAvatar(child: Icon(Icons.person)),
title: Text(user.name),
subtitle: Text(user.email),
);
},
),
),
);
}
}
1. 圧倒的な型安全性
Freezedによって、【nullの扱い】【不正な状態遷移】【データの不変性】が保証されます。
コンパイル時にエラーを検出できるため、ランタイムエラーが劇的に減少します。
2. 開発速度の向上
copyWithによる状態更新が一行で完結- ボイラープレートコードが自動生成されるため、本質的なロジックに集中できる
- Hooksによりライフサイクル管理が簡潔になる
3. テストの容易性
RiverpodのoverrideWithValueにより、テスト時の状態管理が簡単。
ViewModelの単体テストも書きやすくなります。
test('ユーザー一覧の取得に成功', () async {
final mockRepository = MockUserRepository();
when(mockRepository.fetchUserList())
.thenAnswer((_) async => UserListResponse(results: [testUser]));
final container = ProviderContainer(
overrides: [
userRepositoryProvider.overrideWithValue(mockRepository),
],
);
final viewModel = container.read(userListViewModelProvider.notifier);
await viewModel.fetchUserList();
final state = container.read(userListViewModelProvider);
expect(state.users.length, 1);
expect(state.isLoading, false);
});
dependencies:
flutter:
sdk: flutter
hooks_riverpod: ^2.4.0
flutter_hooks: ^0.20.0
freezed_annotation: ^2.4.0
json_annotation: ^4.8.0
dev_dependencies:
build_runner: ^2.4.0
freezed: ^2.4.0
json_serializable: ^6.7.0
プロジェクト開始時に以下のスクリプトを用意しておきます。
#!/bin/bash
# generate.sh
flutter pub get
flutter pub run build_runner build --delete-conflicting-outputs
開発中はwatchモードで自動生成
flutter pub run build_runner watch --delete-conflicting-outputs
チーム全体で以下の命名規則を統一することが重要です。
- ページ:
{feature}_page.dart(例:user_list_page.dart) - ViewModel:
{feature}_viewmodel.dart(例:user_list_viewmodel.dart) - State:
{feature}_state.dart(例:user_list_state.dart) - Repository:
{domain}_repository.dart(例:user_repository.dart) - Model:
{entity}.dart(例:user.dart)
これにより、ファイルの検索が容易になり、チームメンバー全員が同じ構造でコードを書けます。
プロジェクト新規立ち上げ時の設計指針とチェックリスト
初期設定で決めておくべき重要事項
Flutterプロジェクトを新規に立ち上げる際、最初に以下の項目を明確にしておくことで、後々の手戻りを防げます。
プロジェクト作成時に適切なオプションを指定することが重要です:
# fvmを使用する場合の推奨コマンド
fvm use 3.29.0 --force
fvm flutter create . \
--project-name my_app \
--platforms android,ios \
--org com.example \
-e
各オプションの意味:
--project-name: アプリ名(アンダースコア区切り、小文字のみ)--platforms: 対象プラットフォーム(不要なものは除外してファイル削減)--org: 組織ドメイン(逆順DNS形式)-e: デフォルトのサンプルコードを除外(クリーンな状態で開始)
将来のネイティブ拡張を見越して、モダンな言語を選択します:
- iOS: Swift(Objective-Cではなく)
- Android: Kotlin(Javaではなく)
flutter create my_app -i swift -a kotlin
チーム全員が同じFlutterバージョンを使用するため、【fvm(Flutter Version Management)】の導入を強く推奨します:
# プロジェクトルートでバージョン固定
fvm use 3.29.0
# .gitignoreに追加
echo ".fvm/" >> .gitignore
# チームメンバーはこれでセットアップ
fvm install
fvm flutter pub get
設計の基本原則とベストプラクティス
状態は一箇所でのみ管理し、複数箇所での重複を避けます。
Riverpodのグローバルプロバイダーがこれを実現します。
データの流れを【View → ViewModel → Repository → API】の一方向に保ち、予測可能性を高めます。
Freezedを使って【不変なデータクラス】を定義し、状態の予期せぬ変更を防ぎます。
- UIロジック: ボタンの有効/無効、表示/非表示など
- ビジネスロジック: データの取得、加工、バリデーションなど
- データアクセス: API通信、ローカルDB操作など
これらを明確に分離します。
開発開始前のチェックリスト
プロジェクト立ち上げ時に以下をチェックすることで、スムーズな開発が可能になります。
- [ ] アーキテクチャパターン(MVVM + Repository推奨)
- [ ] 状態管理手法(Riverpod推奨)
- [ ] ルーティング方式(go_router推奨)
- [ ] API通信ライブラリ(Dio + Retrofit推奨)
- [ ] ローカルストレージ(shared_preferences / Hive / Isar)
- [ ] 画像キャッシュ(cached_network_image)
- [ ] ロギング(logger)
- [ ]
/model- ドメインモデル - [ ]
/api- APIクライアント - [ ]
/repository- Repository層 - [ ]
/page- 画面UI(配下に画面ごとのフォルダ) - [ ]
/provider- Riverpod Provider定義 - [ ]
/util- ユーティリティ(logger, router等) - [ ]
/constant- 定数・設定 - [ ]
/extension- 拡張機能
- [ ]
pubspec.yaml- 依存パッケージの定義 - [ ]
analysis_options.yaml- Lintルールの設定 - [ ]
.gitignore- バージョン管理除外設定 - [ ]
README.md- プロジェクト概要とセットアップ手順
- [ ] fvmによるFlutterバージョン固定
- [ ] build_runnerのセットアップ
- [ ] IDEプラグインのインストール(Dart, Flutter)
- [ ] コードフォーマッター設定(
dart format .) - [ ] エミュレータ/シミュレータの動作確認
コード品質を保つための初期設定
analysis_options.yamlの推奨設定:
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
errors:
invalid_annotation_target: ignore
linter:
rules:
# 推奨ルール
- prefer_const_constructors
- prefer_const_literals_to_create_immutables
- prefer_final_fields
- prefer_final_locals
- avoid_print
- use_key_in_widget_constructors
- sort_constructors_first
- always_declare_return_types
README.mdのテンプレート:
新規プロジェクトでは、以下の内容を含むREADME.mdを作成します。
# Project Name
## 使用技術
### 状態管理
- hooks_riverpod
- flutter_hooks
- state_notifier
### API通信
- dio
- retrofit
### モデル
- freezed
- json_serializable
## セットアップ
### 1. 依存関係のインストール
\`\`\`bash
fvm flutter pub get
\`\`\`
### 2. コード生成
\`\`\`bash
fvm flutter pub run build_runner build --delete-conflicting-outputs
\`\`\`
### 3. アプリの起動
\`\`\`bash
fvm flutter run
\`\`\`
## アーキテクチャ
[ARCHITECTURE.md](ARCHITECTURE.md)を参照
このようなドキュメントを初期段階で整備することで、新メンバーのオンボーディングや将来の自分への備忘録として機能します。
具体的なREADME.mdやARCHITECTURE.mdは先のページで紹介します。






