はじめに
Todoアプリを4層のクリーンアーキテクチャで実装しました。
Flutter, Riverpod, Firebaseを使用し、各層の債務を明確に分離した構成になっています。
できること
- クリーンアーキテクチャの設計に沿ったTodoアプリの実装
- 状態管理はRiverpod, データ保存にはFirebaseを使用
- add / fetch / update / delete の基本機能に対応
目次
番外編
- ControllerとUsecaseの違い
- 入力欄のバリデーションの責任分担
- Firestoreのデータ整形はどこでやる?
- デフォルト値の補完はどの層?
- Formatter関数はどこに書く?
- ViewModelは必要か?
参考コード
今回実装した参考コードになります。
クリーンアーキテクチャとは?
アプリの「責務(役割)」を層に分けて整理し、変更に強く・テストしやすくする設計の考え方。
層 | 役割 | 例 |
---|---|---|
Presentation | ユーザーに見せる/UI(画面・状態管理) |
Widget , StateNotifier
|
Application | ユースケース(何をしたいのか) |
addTodoUsecase , fetchTodoUsecase
|
Domain | アプリの本質的なルールやデータ構造 |
TodoModel , TodoRepository (インターフェース) |
Infrastructure | 外部サービスとのやりとり |
FirebaseTodoDataSource , TodoRepositoryImpl
|
ディレクトリ構成
クリーンアーキテクチャの4層構造で設計しました。
作成したディレクトリ構成
.
├── feature
│ └── todo
│ ├── application
│ │ └── todo_usecases.dart
│ ├── domain
│ │ ├── model
│ │ │ ├── todo_model.dart
│ │ │ ├── todo_model.freezed.dart
│ │ │ └── todo_model.g.dart
│ │ └── todo_repository.dart
│ ├── infrastructure
│ │ ├── data_source
│ │ │ └── firebase_todo_data_source.dart
│ │ └── todo_repository_impl.dart
│ ├── presentation
│ │ ├── controller
│ │ │ └── todo_controller.dart
│ │ ├── todo_page.dart
│ │ └── widget
│ │ ├── app_text_field.dart
│ │ └── todo_dialog.dart
│ └── todo_providers.dart
├── firebase_options.dart
└── main.dart
Flutter create
flutter create --platforms=android,ios --empty my_app
Presentation層
UIと状態管理(StateNotifier)を定義します。
- page
- TodoPage
- widget
- AppTextFiled
- AppDialog
- controller
- TodoController
- StateNotifierを使用した状態管理
- TodoController
Application層
機能ごとのユースケースを定義します。
- TodoUsecases
- Todoのユースケースを作成
- fetch
- add
- update
- delete
- Todoのユースケースを作成
Domain層
アプリの本質的なルール(モデル抽象リポジトリ)を定義します。
- TodoModel
- freezedを使用
- Todorepository
- 抽象リポジトリ
Infrastructure層
Firebaseとの通信やデータの取得・保存などの実装を行います。
- datasource
- FirebaseTodoDataSource
- Firestoreからのデータ取得・保存を担当
- FirebaseTodoDataSource
- repositoryImpl
- TodoRepositoryImpl
- TodoRepository を実装し、データソースを呼び出す層
- TodoRepositoryImpl
実装まとめ
責務を分担することで実装が明確になり、理解も深まります。
Todoアプリを作成するだけなら、オーバーエンジニアリングだと感じます。
番外
ControllerとUsecaseの違い
層 | 役割 | 書く内容 |
---|---|---|
Controller(Presentation層) | UI のための状態管理と操作受付 | ユースケースの呼び出し、画面状態の更新(ローディング・エラーなど) |
Usecase(Application層) | アプリの機能単位の処理フロー | ビジネスロジックをまとめる。Repositoryの呼び出し、バリデーションなど |
入力欄のバリデーションの責任分担
Controller(UIに近い層)
- ユーザーが入力した瞬間にバリデーション(リアルタイム)
- TextFieldの errorText に表示したり、ボタンの onPressed を制御
- フォーカスアウト時にヒントを表示するなど、UX目的
if (title.isEmpty) {
setState(() => _errorText = 'タイトルを入力してください');
return;
}
Usecase(ビジネスルールを守る層)
- アプリの処理として 絶対に許されない値 をチェック
Future<void> add(String title) async {
if (title.trim().isEmpty) {
throw Exception('タイトルは空にできません');
}
...
}
バリデーションの責任分担
チェック場所 | 目的 | 実装する場所 |
---|---|---|
入力直後・リアルタイム | UX改善・エラー表示 | Controller(Presentation層) |
保存直前・本質的な制約 | データの正当性 | Usecase(Application層) |
Firestoreのデータ整形はどこでやる?
Firestoreの生データ(Mapなど)を整形してモデルに変換する処理は、Infrastructure層で実施します。
理由 | 説明 |
---|---|
Firestoreの形式(例:snake_case、Timestamp型など)は外部仕様 | アプリ内部の責務ではなく、外部との接点に閉じ込めるべき |
Domain層やUsecase層はFirestoreの存在を知らない設計が理想 | 将来 REST API に変わっても変更はInfrastructureだけで済むように |
「整形」はデータ受け取り時の実装の一部 | 生→整形済モデルは、外部→内部の典型的な変換処理 |
デフォルト値の補完はどの層?
デフォルト値の補完は、原則として Infrastructure層 または Domain層 に書く。
目的 | 書く場所 | 理由・特徴 |
---|---|---|
🔸 Firestoreの欠損フィールド対策 | Infrastructure層 | 外部データの整形時に補う(例: data['title'] ?? '無題' ) |
🔸 モデルが持つべき論理的な初期値 | Domain層 | アプリの仕様として意味のある初期値(例: isDone = false ) |
🔸 UI表示用の初期値(フォームなど) | Presentation層 | 状態管理側で補完(例: TextFieldController の初期値) |
Formatter関数はどこに書く?
Formatter関数は Presentation層 に書く。
ディレクトリ構成例
lib/
└── feature/
└── todo/
└── presentation/
├── utils/
│ ├── type_formatter.dart
│ └── date_formatter.dart
NGな書き方(Domain層やModel内に書く)
// NG例: Domain層にUI表示の知識を持たせてしまう
class TodoModel {
String get typeLabel => type == 1 ? '仕事' : 'その他';
}
まとめ
内容 | 書く場所 | 理由 |
---|---|---|
enum/type → 表示名 | Presentation/utils/type_formatter.dart | 表示専用のロジックだから |
DateTime → 表示形式 | Presentation/utils/date_formatter.dart | 日付フォーマットはUI都合 |
UIの色やアイコン | ViewModelやFormatterに分離 | 表示専用の責務 |
ViewModelは必要か?
FlutterでViewModelは「必須」ではありませんが、UIが複雑になるなら導入するとスッキリします。
[TodoModel] (Domain)
↓
[StateNotifier] → 状態保持
↓
[TodoViewModel] → UI表示用に変換
↓
[Widget] → 実際の画面表
TodoViewModel(Presentation層)
コードを見る
class TodoViewModel {
final TodoModel model;
TodoViewModel(this.model);
String get typeLabel {
switch (model.type) {
case 1: return '仕事';
case 2: return '勉強';
default: return 'その他';
}
}
IconData get icon {
switch (model.type) {
case 1: return Icons.work;
case 2: return Icons.school;
default: return Icons.task;
}
}
Color get color {
switch (model.type) {
case 1: return Colors.blue;
case 2: return Colors.green;
default: return Colors.grey;
}
}
bool get isDone => model.isDone;
String get title => model.title;
}
TodoController(StateNotifier)
コードを見る
class TodoController extends StateNotifier<List<TodoModel>> {
TodoController() : super([]);
void add(String title, int type) {
final todo = TodoModel(
id: DateTime.now().toIso8601String(),
title: title,
type: type,
);
state = [...state, todo];
}
void toggle(String id) {
state = state.map((t) {
return t.id == id ? t.copyWith(isDone: !t.isDone) : t;
}).toList();
}
}
UIで ViewModel を使う
コードを見る
final todos = ref.watch(todoControllerProvider);
return ListView(
children: todos.map((todo) {
final vm = TodoViewModel(todo);
return ListTile(
title: Text(vm.title),
subtitle: Text(vm.typeLabel),
leading: Icon(vm.icon, color: vm.color),
trailing: Checkbox(
value: vm.isDone,
onChanged: (_) {
ref.read(todoControllerProvider.notifier).toggle(todo.id);
},
),
);
}).toList(),
);