Flutter のアーキテクチャで迷ったら、まず Flutter チームが公開している公式アーキテクチャガイドを基準にするのが近道です。ガイドは「UI層とデータ層を分ける」「UI層は View と ViewModel に分ける(MVVM)」「データ層は Repository と Service に分ける」を中心に、推奨事項を「Strongly recommend/Recommend/Conditional」の3段階で示しています。
この記事では、ガイドの推奨事項・レイヤーの役割・公式サンプル Compass のディレクトリ構成を一次情報から整理し、Repository・Result・Command・依存性注入・テストの最小構成と、ガイド後半の「デザインパターン」からオフラインファーストの Repository を示します。掲載したコードは 2026年10月29日公開の Flutter 3.47.9(Dart 3.13.7)で flutter analyze を通し、flutter test でテスト7件が成功することを確認しました。サンプルのパッケージ名は arch_sample です。pubspec.yaml の name: もこの名前に揃えてください。依存パッケージは provider(^6.1.5)と、開発用の flutter_test・flutter_lints だけです。ガイドが選んだ MVVM 自体の仕組みはMVVMとは?ViewModelの役割とデータバインディングを動くC#コードで解説、Flutter 3.47 の変更点はFlutter 3.47とは?material_ui移行・iOS 15と3.47.7までの修正【2026年10月時点】で扱っています。
まとめ:Flutterアーキテクチャのベストプラクティスの要点
- 公式ガイドの土台は「UI層とデータ層の分離」です。UI層は View(ウィジェット)と ViewModel、データ層は Repository と Service で構成します。
- ドメイン層(UseCase)は任意です。複数の Repository をまとめる、ロジックが非常に複雑、複数の ViewModel で使い回す、のいずれかに当てはまるときだけ足します。
- ディレクトリは
ui/を機能別、data/を種類別にする組み合わせ型が公式サンプルの構成です。パッケージを分けるなら Dart 3.6.0 以降の pub workspaces で依存を1つに揃えます。 - 依存性注入は強く推奨(Strongly recommend)されており、公式は
providerパッケージを推奨しています。状態管理を ChangeNotifier にするかは Conditional で、Riverpod や flutter_bloc でもガイドの原則は守れます。 - オフライン対応・楽観的UI・端末内保存は、ガイドの「デザインパターン」に Repository と ViewModel の分担として書かれています。オフラインファーストは Repository が Stream で端末内のデータ、サーバーのデータの順に流す形が基本です。
- テストは Repository を抽象クラスにし、Fake を差し込んで ViewModel と View を個別に検証します。
- Dart 3.12 以降は、ガイドの
: _service = serviceという書き方にprefer_initializing_formalsの info が出ます。{required this._service}と書けば解消します。
Flutter公式ガイドのレイヤー構成と各クラスの責務
ガイドはアプリを UI層とデータ層の2層に分け、必要な場合だけ両者の間にドメイン層を置く構成を示しています。ガイドの説明では、Repository と Service は「アプリのデータ、つまり MVVM の Model 層」にあたります。ここでの Model は単一のデータクラスではなく、Repository と Service を含むデータ層を指します。
各クラスがどこまで他を知ってよいかは、ケーススタディの依存性注入のページに「rules of engagement」として明記されています。
| クラス | 層 | 知ってよい相手 | 関係 |
|---|---|---|---|
| View | UI | 自分の ViewModel 1つだけ | View と ViewModel は1対1 |
| ViewModel | UI | 1つ以上の Repository | Repository とは多対多 |
| Repository | データ | 複数の Service | Repository 同士は互いを知らない |
| Service | データ | なし(状態も持たない) | Repository とは多対多 |
依存の向きは常に上から下で、データは Repository から ViewModel、View の順に流れます。ユーザー操作は逆向きに ViewModel へ渡り、Repository に処理を頼みます。ガイドはこの一方向のデータフローも Strongly recommend に置いています。
見落としやすいのが「Repository 同士は互いを知らない」という規則です。ユーザー情報と予約情報を合わせて表示したいとき、BookingRepository から UserRepository を呼ぶのではなく、ViewModel かドメイン層で結合します。Repository 同士が参照し合うと依存関係が入り組み、1つの Repository をテストするのに別の Repository の Fake まで用意することになります。
公式ガイドの推奨事項と優先度
Architecture recommendations のページは、各推奨事項に3段階の優先度を付けています。Strongly recommend は「新規アプリなら常に実装し、既存アプリでも根本的に方針と衝突しない限りリファクタリングを強く検討する」、Recommend は「実装するとアプリが改善する見込みが高い」、Conditional は「特定の状況で改善する」という意味です。
| 優先度 | 推奨事項 | 分類 |
|---|---|---|
| Strongly recommend | データ層と UI層を明確に分ける | 関心の分離 |
| Strongly recommend | データ層で Repository パターンを使う | 関心の分離 |
| Strongly recommend | UI層で View と ViewModel を使う(MVVM) | 関心の分離 |
| Strongly recommend | ウィジェットにロジックを置かない | 関心の分離 |
| Strongly recommend | 一方向のデータフロー | データの扱い |
| Strongly recommend | イミュータブルなデータモデル | データの扱い |
| Strongly recommend | 依存性注入を使う | アプリ構成 |
| Strongly recommend | Repository を抽象クラスにする | アプリ構成 |
| Strongly recommend | 各コンポーネントを個別と結合の両方でテスト | テスト |
| Strongly recommend | テスト用に Fake を作る | テスト |
| Recommend | ユーザー操作は Command で扱う | データの扱い |
| Recommend | freezed か built_value でモデルを生成 | データの扱い |
| Recommend | 画面遷移は go_router を使う | アプリ構成 |
| Recommend | クラス・ファイル・ディレクトリの命名を統一 | アプリ構成 |
| Conditional | ChangeNotifier と Listenable で更新を通知 | 関心の分離 |
| Conditional | ドメイン層を使う | 関心の分離 |
| Conditional | API モデルとドメインモデルを分ける | データの扱い |
この表では、Flutter チームは「層の分け方とテストのしやすさ」を強く推奨し、「状態管理の道具」と「ドメイン層の有無」は状況次第にしています。ページ冒頭にも、これらは絶対の規則ではなくアプリの要件に合わせて調整する推奨事項だと書かれています。ChangeNotifier について、ガイドは状態管理の選択肢は多く「最終的には好みの問題」と書いています。
ウィジェットに置いてよいロジックも具体的に列挙されています。ViewModel のフラグや null 許容フィールドで表示を切り替える単純な if 文、ウィジェット側で計算が必要なアニメーション、画面サイズや向きに応じたレイアウト、単純な画面遷移の4つです。それ以外の分岐や計算は ViewModel のメソッドに移します。
freezed は推奨事項に入っていますが、ガイドはモデルが多いとビルド時間が大きく延びる点も注記しています。導入の判断はFreezed(Flutter)とは?データクラスとUnion型の生成と依存の固定【2026年8月時点】を参照してください。
Flutterのディレクトリ構成:機能別と種類別の組み合わせ
コードの整理方法には、機能ごとにまとめる「By feature」と、Repository・ViewModel などの種類ごとにまとめる「By type」があります。ケーススタディは、ガイドのアーキテクチャは両者の組み合わせに向いているとして、公式サンプル Compass の構成を次のように示しています。
lib/
├─ ui/
│ ├─ core/
│ │ ├─ ui/ # 複数画面で使う共通ウィジェット
│ │ └─ themes/
│ └─ <feature_name>/ # 機能ごと(View 1つ + ViewModel 1つ)
│ ├─ view_models/
│ │ └─ <view_model_class>.dart
│ └─ widgets/
│ ├─ <feature_name>_screen.dart
│ └─ <other_widgets>
├─ domain/
│ └─ models/
│ └─ <model_name>.dart
├─ data/
│ ├─ repositories/
│ │ └─ <repository_class>.dart
│ ├─ services/
│ │ └─ <service_class>.dart
│ └─ model/
│ └─ <api_model_class>.dart
├─ config/
├─ utils/
├─ routing/
├─ main_staging.dart
├─ main_development.dart
└─ main.dart
test/ # lib/ と同じ構造でテストを置く
testing/ # Fake などテスト用の部品(出荷しない)
ui は機能別、data は種類別にする理由
理由はケーススタディに書かれています。View と ViewModel は機能ごとにちょうど1組なので、機能単位でまとめると関係するファイルが1か所に集まります。一方、Repository と Service は複数の機能・複数の ViewModel から使われるため、特定の機能フォルダに入れると置き場所が決まりません。そこで data/ は種類別にします。domain/ にはデータ層と UI層の両方が使うデータ型を置きます。
実際の Compass では、ui/ の直下に activities・auth・booking・core・home・results・search_form の7フォルダが並んでいます(GitHub の flutter/samples リポジトリで確認)。
命名規則と共通ウィジェットの置き場所
ガイドはクラス名をアーキテクチャ上の役割で付けることを推奨し、HomeViewModel・HomeScreen・UserRepository・ClientApiService を例に挙げています。共通ウィジェットのフォルダを /widgets にせず ui/core/ にするのは、Flutter SDK の用語と紛らわしい名前を避けるためです。
エントリポイントを main_development.dart・main_staging.dart・main.dart の3つに分けているのは、環境ごとに注入する Repository の実装を切り替えるためです。抽象 Repository を推奨する理由として、ガイドも「development」と「staging」で別実装を使える点を挙げています。
小規模アプリで構成を縮める方法
Compass の構成をそのまま写すと、画面が数枚のアプリではフォルダが空のまま並びます。小さく始めるなら、ui/・data/・domain/ の3分割と抽象 Repository を出発点にし(ここでの domain/ はモデルの置き場所で、任意の UseCase 層とは別です)、config/・routing/・環境別の main は必要になってから足すのが現実的です。この記事のサンプルは次の構成です。
lib/
├─ ui/
│ ├─ core/
│ └─ todo/
│ ├─ view_models/todo_view_model.dart
│ └─ widgets/todo_screen.dart
├─ domain/models/todo.dart
├─ data/
│ ├─ repositories/
│ │ ├─ todo_repository.dart # 抽象クラス
│ │ └─ todo_repository_remote.dart # 本番実装
│ └─ services/todo_api_service.dart
├─ utils/
│ ├─ command.dart
│ └─ result.dart
└─ main.dart
testing/fakes/fake_todo_repository.dart
test/
逆に、後から足すと手戻りが大きいのは ui/ と data/ の分離です。ウィジェットの中で HTTP を直接呼ぶコードが増えてから分けると、画面ごとに書き直しになります。最初から Compass に近い構成で始めたい場合は、公式ガイドの推奨リソースに載っている Very Good Ventures のテンプレート生成ツール very_good_cli(2026年10月30日時点の pub.dev 最新版は 1.5.0)も選択肢です。
複数パッケージに分ける場合:pub workspaces
アプリ本体・API クライアント・サーバーなどを別パッケージに分けて同じリポジトリで管理するなら、Dart 3.6.0 で入った pub workspaces を使います。ルートの pubspec.yaml に workspace: で各パッケージを並べ、各パッケージ側に resolution: workspace を書くと、pubspec.lock と .dart_tool/package_config.json がルートに1つだけ作られ、全パッケージが同じ版の依存を使います。公式サンプルを収めた flutter/samples リポジトリもこの形で、Compass の compass_app/app と compass_app/server はワークスペースのメンバーです。
# ルートの pubspec.yaml
name: _
publish_to: none
environment:
sdk: ^3.11.0
workspace:
- packages/* # glob 指定はルートの SDK 制約が 3.11 以上のときだけ使える
# packages/app/pubspec.yaml など各パッケージ側
environment:
sdk: ^3.6.0
resolution: workspace
Dart のドキュメントは、依存を1つに揃えるぶん衝突が起きやすくなるが、一緒に使うパッケージ同士なら衝突は早く見つかった方がよいと説明しています。画面数が少ないうちは1パッケージの中のフォルダ分けで十分で、パッケージ分割はサーバーや別アプリとモデルを共有する必要が出てからにします。
データ層の実装:Service・Repository・Result
Service は外部 API をラップするだけで、状態を持ちません。Repository は Service から受け取った生データをドメインモデルに変換し、キャッシュ・エラー処理・リトライなどを担う「データの唯一の正しい置き場所(source of truth)」です。エラーは例外を投げっぱなしにせず、公式のResult パターンで返り値として呼び出し側に渡します。
// lib/utils/result.dart
sealed class Result<T> {
const Result();
const factory Result.ok(T value) = Ok._;
const factory Result.error(Exception error) = Error._;
}
final class Ok<T> extends Result<T> {
const Ok._(this.value);
final T value;
}
final class Error<T> extends Result<T> {
const Error._(this.error);
final Exception error;
}
モデルと Service は次のとおりです。Service は API を呼ぶ部分を差し替えやすいよう、変換前の生データ(Map)を返すだけにしています。
// lib/domain/models/todo.dart
class Todo {
const Todo({required this.id, required this.title});
final int id;
final String title;
}
// lib/data/services/todo_api_service.dart
// Service:外部APIを呼ぶだけ。状態を持たない
class TodoApiService {
Future<List<Map<String, Object?>>> fetchTodos() async {
// 実際は http パッケージ等で GET する
return [
{'id': 1, 'title': '設計レビュー'},
];
}
}
Repository は抽象クラスと本番実装に分けます。抽象クラスにしておくと、本番実装と Fake が同じ契約を実装する形になり、環境やテストごとに差し替えやすくなります。
// lib/data/repositories/todo_repository.dart
import '../../domain/models/todo.dart';
import '../../utils/result.dart';
// 抽象Repository:本番実装とFakeを差し替えられるようにする
abstract class TodoRepository {
Future<Result<List<Todo>>> getTodos();
}
// lib/data/repositories/todo_repository_remote.dart
import '../../domain/models/todo.dart';
import '../../utils/result.dart';
import '../services/todo_api_service.dart';
import 'todo_repository.dart';
class TodoRepositoryRemote implements TodoRepository {
TodoRepositoryRemote({required this._apiService});
final TodoApiService _apiService;
List<Todo>? _cache;
@override
Future<Result<List<Todo>>> getTodos() async {
if (_cache != null) return Result.ok(_cache!);
try {
final json = await _apiService.fetchTodos();
_cache = List.unmodifiable([
for (final e in json)
Todo(id: e['id'] as int, title: e['title'] as String),
]);
return Result.ok(_cache!);
} on Exception catch (e) {
return Result.error(e);
}
}
}
コンストラクタの {required this._apiService} は、Dart 3.12.0 で入った private named parameters という書き方です。呼び出し側は apiService: という名前で渡します。公式ガイドのコード例は MyRepository({required MyService myService}) : _myService = myService; という形ですが、Dart 3.12 以降の flutter_lints(6.0.0 で確認)ではこの形に prefer_initializing_formals の info が出ます。既存コードは dart fix --code=prefer_initializing_formals --apply でまとめて書き換えられます。Dart 3.11 以前ではアンダースコアで始まる名前付き引数はコンパイルエラーなので、SDK 制約が 3.12 未満のパッケージではガイドの形のままにします。
UI層の実装:ViewModelとCommandによる二重実行の防止
ViewModel はデータを UI 用の状態に変換し、View からの操作を受け付けます。ガイドが Recommend に置く Command パターンは、非同期の操作を「実行中」「成功」「失敗」の状態付きで包むクラスです。下のコードは Compass の utils/command.dart から、引数なしの Command0 だけを抜き出して簡略化したものです。
// lib/utils/command.dart
import 'package:flutter/foundation.dart';
import 'result.dart';
class Command0<T> extends ChangeNotifier {
Command0(this._action);
final Future<Result<T>> Function() _action;
bool _running = false;
Result<T>? _result;
bool get running => _running;
bool get error => _result is Error;
Future<void> execute() async {
if (_running) return; // 連打による二重実行を止める
_running = true;
_result = null;
notifyListeners();
try {
_result = await _action();
} finally {
_running = false;
notifyListeners();
}
}
}
if (_running) return; の1行が、ボタンの連打による二重実行を止めます。この行を外して後述のテストを実行すると、Repository の呼び出し回数は期待値1に対して2になり、テストが失敗しました。Command を自作せずに済ませたい場合、公式の Command ページは pub.dev の既製実装として command_it(2026年10月30日時点で 9.5.2)を挙げています。数十行で済むクラスなので、外部パッケージを増やしたくなければ Compass の実装を写すほうが依存は少なく済みます。
// lib/ui/todo/view_models/todo_view_model.dart
import 'package:flutter/foundation.dart';
import '../../../data/repositories/todo_repository.dart';
import '../../../domain/models/todo.dart';
import '../../../utils/command.dart';
import '../../../utils/result.dart';
class TodoViewModel extends ChangeNotifier {
TodoViewModel({required this._todoRepository}) {
load = Command0(_load);
}
final TodoRepository _todoRepository;
late final Command0<void> load;
List<Todo> _todos = [];
List<Todo> get todos => List.unmodifiable(_todos);
Future<Result<void>> _load() async {
final result = await _todoRepository.getTodos();
switch (result) {
case Ok<List<Todo>>():
_todos = result.value;
notifyListeners();
return const Result.ok(null);
case Error<List<Todo>>():
return Result.error(result.error);
}
}
}
View は ViewModel をコンストラクタで受け取り、表示の切り替えだけを行います。ViewModel 本体と Command の両方の変更を拾うため、Listenable.merge でまとめて監視します。
// lib/ui/todo/widgets/todo_screen.dart
import 'package:flutter/material.dart';
import '../view_models/todo_view_model.dart';
class TodoScreen extends StatelessWidget {
const TodoScreen({super.key, required this.viewModel});
final TodoViewModel viewModel;
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListenableBuilder(
listenable: Listenable.merge([viewModel, viewModel.load]),
builder: (context, _) {
if (viewModel.load.running) {
return const Center(child: CircularProgressIndicator());
}
if (viewModel.load.error) {
return Center(
child: TextButton(
onPressed: viewModel.load.execute,
child: const Text('再読み込み'),
),
);
}
return ListView(
children: [
for (final todo in viewModel.todos) Text(todo.title),
],
);
},
),
);
}
}
依存性注入:providerとget_it・Riverpodの位置づけ
ガイドの依存性注入は、まず「必要なものをコンストラクタ引数で受け取る」ことです。Service を Repository に、Repository を ViewModel に渡します。その上で、インスタンスをどこで作ってどう配るかに provider パッケージを使うよう推奨しています。Compass はアプリの起点で MultiProvider に Service と Repository を登録し、context.read() で取り出して渡しています。
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'data/repositories/todo_repository.dart';
import 'data/repositories/todo_repository_remote.dart';
import 'data/services/todo_api_service.dart';
import 'ui/todo/view_models/todo_view_model.dart';
import 'ui/todo/widgets/todo_screen.dart';
void main() {
runApp(
MultiProvider(
providers: [
Provider(create: (context) => TodoApiService()),
Provider<TodoRepository>(
create: (context) =>
TodoRepositoryRemote(apiService: context.read()),
),
],
child: const MainApp(),
),
);
}
class MainApp extends StatefulWidget {
const MainApp({super.key});
@override
State<MainApp> createState() => _MainAppState();
}
class _MainAppState extends State<MainApp> {
// build ではなく initState で1回だけ作る(再buildで読み込みをやり直さない)
late final TodoViewModel _viewModel;
@override
void initState() {
super.initState();
_viewModel = TodoViewModel(todoRepository: context.read())..load.execute();
}
@override
void dispose() {
_viewModel.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(home: TodoScreen(viewModel: _viewModel));
}
}
ViewModel は build の中で作らないでください。build は親の再構築などで何度も呼ばれるため、StatelessWidget の build で作る形だと、親を2回再構築するテストで Repository が3回呼ばれ、そのたびに状態も作り直されます。initState に移すと呼び出しは1回になります。
Repository は抽象型 TodoRepository として登録しています。環境別の main でここを別の実装に変えるだけで、ViewModel 以下のコードを触らずに接続先を切り替えられます。
依存性注入の道具は provider に限りません。2026年10月30日時点の pub.dev の最新版は次のとおりです。
| パッケージ | 最新版 | アーキテクチャ上の役割 |
|---|---|---|
| provider | 6.1.5+1 | 公式推奨の DI。ウィジェットツリー経由で配る |
| get_it | 9.3.0 | サービスロケーター。BuildContext 不要 |
| injectable | 3.0.0 | get_it の登録コードを生成 |
| flutter_riverpod | 3.4.3 | DI と状態管理を兼ねる |
| flutter_bloc | 9.1.1 | Bloc/Cubit で状態管理、RepositoryProvider で DI |
| go_router | 18.1.2 | 公式推奨の画面遷移(Flutter 3.44.0 以上が必要) |
get_it はどこからでも取り出せる分、ガイドが避けたい「グローバルにアクセスできるオブジェクト」になりやすい点に注意が必要です。get_it を使う場合も、取り出すのはアプリの起点だけにして、各クラスにはコンストラクタで渡すと規則を守れます。Flutter のドキュメントリポジトリ(flutter/website)に 2026年8月25日に追加された、コードを書く AI エージェント向けの指示書 write-flutter も同じ立場で、グローバルなシングルトンとサービスロケーターの呼び出しを避け、ロケーターを使うプロジェクトでも呼び出しはオブジェクトを組み立てる場所に限るよう書いています。
状態管理ライブラリと公式レイヤーの対応関係
ケーススタディは、Compass の UI は ChangeNotifier に寄せているが、Stream や Riverpod・flutter_bloc・signals を使っても同じ規則を守って実装できると説明しています。つまり公式ガイドは「状態管理ライブラリの選び方」ではなく「どのクラスに何を置くか」の指針です。ライブラリを替えても、置き換わるのは主に ViewModel の書き方です。
| 公式ガイド | Riverpod | BLoC 公式 |
|---|---|---|
| View | ConsumerWidget | Presentation |
| ViewModel | Notifier / AsyncNotifier | Business Logic(Bloc / Cubit) |
| Repository | Provider で公開する Repository | Data / Repository |
| Service | Provider で公開する API クライアント | Data / Data Provider |
ライブラリを選ぶ前に、その状態がどこまで共有されるかを分けておくと判断がぶれません。前述の write-flutter は、選択中のタブや入力中のテキストのように1つのサブツリーで完結する状態(ローカル状態)は StatefulWidget と setState で足り、ログイン中のユーザーやカートのように複数の箇所が読み書きする状態(アプリ状態)に状態管理の仕組みを使う、と分けています。Riverpod を使うアプリでもアニメーションの開閉は setState でよく、これらは段階的に乗り換えるものではないという立場です。既存プロジェクトでは、具体的な必要が無い限り共有状態の仕組みを2つ目の別パッケージで足さないよう求めています。
BLoC の公式アーキテクチャも Presentation・Business Logic・Data の3層で、Data 層を Repository と Data Provider に分けます。Flutter 公式ガイドの Repository と Service の分け方とほぼ同じ形です。BLoC 側の固有の規則として、Bloc が別の Bloc の stream を購読する書き方は「してはいけない」と明記されています。代わりに、Presentation 層の BlocListener で一方の変化を受けて他方にイベントを送るか、両方の Bloc が同じ Repository の Stream を購読します。公式ガイドの「Repository 同士は互いを知らない」と同じく、部品同士を直接つながない考え方です。
Riverpod で同じ構成を書く手順はFlutterのMVVM実装をRiverpodで書く手順|公式アーキテクチャガイド準拠のViewModel設計、ライブラリ同士の比較はRiverpodと他の状態管理の違い|Provider・Bloc・GetXとの比較と選び方【2026年10月】で詳しく扱っています。
ドメイン層(UseCase)を入れる条件とクリーンアーキテクチャとの違い
「Flutter クリーンアーキテクチャ」で紹介される構成は、Presentation・Domain・Data の3層で、UI からのデータ取得を必ず UseCase 経由にするものが多く見られます。公式ガイドはここで立場がはっきりしていて、ドメイン層は Conditional、つまり任意です。推奨事項のページには、非常に大きなアプリでは UseCase が役立つが「ほとんどのアプリでは不要なオーバーヘッドになる」と書かれています。
ガイドが UseCase に切り出す条件として挙げるのは次の3つです。
- 複数の Repository のデータを結合する必要がある
- ロジックが非常に複雑である
- そのロジックを複数の ViewModel で使い回す
導入の仕方も「必要になったときに足す」が推奨です。すべての ViewModel に UseCase を経由させる方式は、モジュール化とテストのしやすさは最大になる代わりに、クラス数と定型コードが大きく増えるとガイドは説明しています。Compass も、一部の機能だけ UseCase を持ち、他の ViewModel は Repository を直接使っています。ガイドはこの形を、層を積み重ねた「ラザニア」ではなく、UI層とデータ層という2つのメインに、ドメイン層が付け合わせとして添えられた「一皿」と表現しています。
新規アプリでは、最初は UseCase を作らず、ViewModel 同士で同じロジックを書き始めた時点で切り出すことを勧めます。Entity・UseCase・Repository の抽象を全機能に先回りで用意する構成は、画面数が少ないうちは変更のたびに触るファイルを増やすだけです。依存の向きを内側に揃える設計の考え方自体はヘキサゴナルアーキテクチャとは?ポートアンドアダプターの仕組みとクリーン・オニオンとの違いで整理しています。
公式デザインパターンの使いどころ:楽観的UI・オフラインファースト・端末内保存
ガイドの後半には、デザインパターンの記事が6本あります。コード例は前半の MVVM と Repository を前提にしていますが、各パターンはアプリ全体のアーキテクチャにかかわらず、個別の設計課題に適用できます。
| パターン | 解く課題 | 主に書く場所 |
|---|---|---|
| Command | 操作の実行中・成功・失敗を扱う | ViewModel |
| Result | 例外を投げずにエラーを返す | Repository・Service |
| Optimistic state | 完了を待たずに成功表示する | ViewModel |
| Offline-first | 通信できなくても読み書きする | Repository |
| Key-value data | 設定値を端末に保存する | Service(shared_preferences) |
| SQL | 一覧など構造のあるデータを保存する | Service(sqflite) |
Key-value と SQL の2本はどちらも、保存の手段を Service に閉じ込め、ViewModel は Repository だけを見る構成です。保存先を shared_preferences から sqflite に替えても、変わるのは Service と Repository の中身だけになります。
オフラインファーストのRepository:Streamによる読み取り
Offline-first の記事は、読み取りの方法として「通信に失敗したときだけ端末内のデータを使う」「Stream で端末内のデータとサーバーのデータを順に流す」「端末内のデータだけを読む」の3つを挙げ、Stream を使う方法をよりよい選択肢としています。端末内のデータは通信より速く失敗しにくいので、先に流せば ViewModel はすぐ画面を描けます。この記事の Todo に当てはめると次のようになります。ただし、以下の TodoDatabaseService はメモリ上に保存する動作説明用の代替実装で、アプリ終了後にはデータが残りません。永続化する場合は、内部を sqflite などによる保存処理に置き換えます。
// lib/data/services/todo_database_service.dart
import '../../domain/models/todo.dart';
// Service:端末内の保存先を扱うだけ。実際は sqflite や shared_preferences を包む
class TodoDatabaseService {
List<Todo>? _stored;
Future<List<Todo>?> fetchTodos() async => _stored;
Future<void> saveTodos(List<Todo> todos) async => _stored = todos;
}
// lib/data/repositories/todo_repository_offline_first.dart
import '../../domain/models/todo.dart';
import '../services/todo_api_service.dart';
import '../services/todo_database_service.dart';
class TodoRepositoryOfflineFirst {
TodoRepositoryOfflineFirst({
required this._apiService,
required this._databaseService,
});
final TodoApiService _apiService;
final TodoDatabaseService _databaseService;
// 端末内のデータを先に流し、通信に成功したら新しいデータをもう一度流す
Stream<List<Todo>> watchTodos() async* {
final local = await _databaseService.fetchTodos();
if (local != null) yield local;
try {
final json = await _apiService.fetchTodos();
final remote = List<Todo>.unmodifiable([
for (final e in json)
Todo(id: e['id'] as int, title: e['title'] as String),
]);
await _databaseService.saveTodos(remote);
yield remote;
} on Exception {
// 端末内にデータがあれば、それを表示したまま終える
if (local == null) rethrow;
}
}
}
Fake の API Service を差し込んだテストでは、保存済みのデータがあるとき Stream は「端末に保存済み」「サーバーの最新」の順に2回値を流し、オフラインにすると保存済みのデータ1回だけで正常に終わりました。保存済みのデータが無くオフラインのときだけエラーが流れます。公式の記事は例外の扱いを省いているため、端末内にデータがあるかどうかでエラーを流すかを分ける部分はこの記事で足したものです。
// test/todo_repository_offline_first_test.dart(抜粋)
test('保存済みのデータ、サーバーの最新の順に2回流れる', () async {
await db.saveTodos(const [Todo(id: 1, title: '端末に保存済み')]);
final titles = await repo.watchTodos().map((t) => t.single.title).toList();
expect(titles, ['端末に保存済み', 'サーバーの最新']);
});
test('保存済みのデータが無くオフラインならエラーを流す', () async {
api.offline = true;
await expectLater(repo.watchTodos(), emitsError(isA<Exception>()));
});
書き込みは2通りです。通信できるときだけ書き込ませる方法はサーバーと状態がずれませんが、オフラインでは保存できません。先に端末へ保存してから送る方法はオフラインでも保存できる代わりに、送信に失敗すると端末とサーバーの内容がずれます。公式の記事は、モデルに synchronized のようなフラグを持たせ、false のデータだけを後で送り直し、成功したら true にする方法と、Firebase Cloud Messaging などのプッシュでサーバー側から同期を促す方法を示しています。メモや下書きのように後から同期してよいデータは先に端末へ保存し、決済や予約のようにサーバーとずれてはいけない操作は通信できるときだけ受け付ける、と操作ごとに決めます。
楽観的UIの状態管理:ViewModelのフラグとCommand
Optimistic state は、「購読する」ボタンを押した瞬間に表示を「購読済み」に変え、API の結果を待たずに成功を見せる手法です。公式の例では ViewModel が subscribed と error の2つのフラグを持ち、押した時点で subscribed を true にし、失敗したときだけ false に戻して error を立てます。View は initState で ViewModel に addListener し、error が立ったら SnackBar を出してフラグを戻します。dispose で removeListener を呼ぶのを忘れないよう、記事でも念を押しています。
送信中であることも見せたい場合は、ViewModel に実行中のフラグを足すか、Command の running を使うよう公式は勧めています。チャットで送ったメッセージを先に表示し、届くまで送信中のアイコンを付ける形がこれにあたります。前述の Command は実行中の管理に再利用できます。ただし、成功状態の先行表示と、失敗時に元へ戻す処理は ViewModel に実装する必要があります。
Fakeを使ったレイヤーごとのテスト
ガイドは、Service・Repository・ViewModel のすべてにユニットテストを書き、View にはウィジェットテストを書くことを Strongly recommend にしています。モックライブラリより先に Fake を勧めているのも特徴で、Compass は FakeBookingRepository のような Fake を testing/fakes/ に置いています。testing/ はケーススタディの言葉で「出荷しないもう1つのアプリ」です。
// testing/fakes/fake_todo_repository.dart
import 'package:arch_sample/data/repositories/todo_repository.dart';
import 'package:arch_sample/domain/models/todo.dart';
import 'package:arch_sample/utils/result.dart';
class FakeTodoRepository implements TodoRepository {
int calls = 0;
bool fail = false;
@override
Future<Result<List<Todo>>> getTodos() async {
calls++;
await Future<void>.delayed(const Duration(milliseconds: 10));
if (fail) return Result.error(Exception('network'));
return const Result.ok([Todo(id: 1, title: 'テスト用タスク')]);
}
}
ViewModel のテストでは、Fake の呼び出し回数で Command の二重実行防止を確かめます。
// test/todo_view_model_test.dart
import 'package:arch_sample/ui/todo/view_models/todo_view_model.dart';
import 'package:flutter_test/flutter_test.dart';
import '../testing/fakes/fake_todo_repository.dart';
void main() {
test('連続で execute しても Repository は1回だけ呼ばれる', () async {
final repo = FakeTodoRepository();
final viewModel = TodoViewModel(todoRepository: repo);
final first = viewModel.load.execute();
final second = viewModel.load.execute(); // 実行中の2回目は無視される
await Future.wait([first, second]);
expect(repo.calls, 1);
expect(viewModel.todos.single.title, 'テスト用タスク');
});
test('失敗すると error が立ち、一覧は空のまま', () async {
final repo = FakeTodoRepository()..fail = true;
final viewModel = TodoViewModel(todoRepository: repo);
await viewModel.load.execute();
expect(viewModel.load.error, isTrue);
expect(viewModel.todos, isEmpty);
});
}
View のテストでは、同じ Fake を使って「読み込み中はインジケータ、完了後に一覧」という表示の切り替えを確かめます。
// test/todo_screen_test.dart
import 'package:arch_sample/ui/todo/view_models/todo_view_model.dart';
import 'package:arch_sample/ui/todo/widgets/todo_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../testing/fakes/fake_todo_repository.dart';
void main() {
testWidgets('読み込み中はインジケータ、完了後に一覧を表示', (tester) async {
final viewModel = TodoViewModel(todoRepository: FakeTodoRepository());
await tester.pumpWidget(MaterialApp(home: TodoScreen(viewModel: viewModel)));
viewModel.load.execute();
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await tester.pumpAndSettle();
expect(find.text('テスト用タスク'), findsOneWidget);
});
}
Flutter 3.47.9 で flutter test を実行すると、この3件と、前章のオフラインファーストの3件、上の main.dart の MainApp を再構築しても読み込みが1回で済むことを確かめる1件の計7件が成功しました。Dart では具象クラスも implements できるので、抽象クラスでなくても Fake 自体は作れます。それでも抽象 Repository にしておくと、本番実装と Fake が同じ契約を実装していることがコード上で明確になり、本番実装の内部メソッドに引きずられずに済みます。画面遷移を含むテストでは、Compass は go_router を mocktail でモックしています。
採用すべきでない構成と失敗パターン
公式ガイドの規則から外れて問題になりやすい構成を、起きる症状と合わせて挙げます。
- ウィジェットの中で API を呼ぶ:
initStateで HTTP を呼び、結果をsetStateで入れる書き方は、通信と表示が1つのクラスに結び付き、テストで通信を差し替える準備が画面ごとに必要になります。通信は Repository に、結果の保持と分岐は ViewModel に移します。 - Repository から別の Repository を呼ぶ:依存が循環しやすく、テストで用意する依存オブジェクトも増えます。結合は ViewModel かドメイン層で行います。
- Repository や Service をグローバル変数やシングルトンで直接参照する:テストのたびに状態が残り、実行順で結果が変わります。コンストラクタで受け取る形にします。
- 全画面に UseCase を先回りで作る:中身が Repository を呼ぶだけの UseCase が並び、変更のたびに触るファイルが増えます。
- 1つの ViewModel を複数の画面で共有する:ガイドの「View と ViewModel は1対1」に反し、ある画面の状態変更が別の画面に波及します。共有したいのはデータなので、Repository に寄せます。
- オフライン保存に同期の印を持たせない:先に端末へ保存して送信に失敗すると、どのデータが未送信か分からなくなり、端末とサーバーの内容がずれたままになります。未送信のフラグを持たせて後から送り直します。
既存アプリの層の分け直しやオフライン対応を含めた開発を外部に任せたい場合は、弊社のFlutter・React Nativeのアプリ開発支援でもご相談いただけます。
よくある質問
Flutterのアーキテクチャはどのパターンを選べばよいですか?
迷ったら公式ガイドの構成(UI層の MVVM + データ層の Repository と Service)を基準にしてください。Flutter チーム自身が Strongly recommend として示している構成で、公式サンプル Compass という完成形のコードも公開されています。BLoC 公式のアーキテクチャもほぼ同じ層の分け方です。
Flutterでクリーンアーキテクチャは必要ですか?
全機能に UseCase を置く構成は、多くのアプリでは不要です。依存の向きを内側に揃える考え方はガイドの層構成でも守れます。公式ガイドはドメイン層(UseCase)を任意とし、複数 Repository の結合・複雑なロジック・複数 ViewModel での再利用のいずれかに当てはまるときだけ足すよう勧めています。最初は UseCase を作らず、必要になった機能から切り出すのが公式の推奨です。
Flutterのフォルダ構成は機能別とレイヤー別のどちらがよいですか?
公式サンプルは両方を組み合わせています。ui/ は View と ViewModel が1組ずつなので機能別、data/ は Repository と Service が複数機能から使われるので種類別です。小規模アプリでもこの分け方は最初から採れます。
公式ガイドに従う場合、RiverpodやBLoCは使えませんか?
使えます。ケーススタディは、Compass を Riverpod・flutter_bloc・signals や Stream で書くこともできたと説明しています。ChangeNotifier の採用は Conditional で、ガイドは状態管理の選択は最終的に好みの問題としています。ライブラリを替えても、層の分け方と依存の向きはそのまま使えます。
既存アプリを公式アーキテクチャへ移行するにはどこから始めればよいですか?
ウィジェットの中にある API 呼び出しを Service と抽象 Repository に移すところから始めます。Repository ができれば Fake を差し込んだテストが書けるので、その後で画面ごとに ViewModel を切り出しても、テストで既存の挙動を確かめながら進められます。go_router や Command の導入は Recommend なので、層の分離が済んでからで十分です。