はじめに
この記事は Life is Tech ! Advent Calendar 2025 の記事です。はるっぺです。
個人的に一番納得感のある、クリーンアーキテクチャを採用したFlutterのディレクトリ構造を考えついたので、これをまとめていきます。
このアーキテクチャと記事はAIと一緒に考えてAIと一緒に書いています。
なんとなくの理解なため、クリーンアーキテクチャの詳しい解説は一次ソースを当たっていただきたいです。
Flutterで中規模〜大規模アプリを作ると、多くの開発者が直面するのが
- 肥大化するUIロジック
- Repository の責務の不明確さ
- データの流れがわかりにくくなる
といった問題です。
これらを解決するためのアプローチがクリーンアーキテクチャです。
本記事では、
- MVVM
- Repository パターン
- クリーンアーキテクチャ
を統合した、実践的なアーキテクチャ例を紹介します。
特定の理論に厳密に従うのではなく、可読性と変更容易性を最優先にした構造です。
ここで紹介するアーキテクチャは、以下のリポジトリで実装されているTODOアプリに基づいています。具体的なコードと合わせて読むことで、より深い理解が得られるはずです。(まだまだ更新中です)
また、本記事の設計思想は、以下の書籍や記事、リポジトリから多大な影響を受けています。先人たちの知見に感謝します。
- Clean Architecture 達人に学ぶソフトウェアの構造と設計
- MVVM+Repositoryパターンを採用したFlutterアプリを構築する
- flutter-architecture-blueprints
- 【Flutter】Clean Architectureに基づいたディレクトリ構成
目指すアーキテクチャの全体像
本稿で提案するアーキテクチャは、関心の分離を徹底するためのレイヤードアーキテクチャです。各レイヤーは決められた方向にのみ依存し、ビジネスロジックの純粋性とUIやインフラとの独立性を高めます。
全体のシステム構成と依存性の流れは以下のようになります。
-
黒色の矢印:
コードレベルでの直接的な依存関係(呼び出しや実装)を示します。例えば、UI層のNotifierがDomain層のUseCaseを呼び出したり、Infrastructure層のRepositoryがDomain層のIRepositoryを実装したりする関係です。 -
ピンク色の矢印:
実行時にDI(依存性注入)によって依存性が解決される関係を示します。
重要なのは、Application層のUseCaseは、Domain層のIRepositoryにしか依存しないという点です。UseCaseは、Repositoryの具体的な実装(Infrastructure層のRepository)が何であるかを知りません。
Provider(このプロジェクトではRiverpodが担当)が接着剤の役割を果たし、UseCaseに対してRepositoryの具象クラス(Infrastructure層のRepository)を注入します。これにより、依存性の方向が常にDomain層(緑のブロック)を向くというクリーンアーキテクチャの原則が守られます。
※ 図ではInfrastructure層のRepositoryがDatasourceに依存していますが、厳密に行う場合はここにもDIを使用するとより良くなると思います。
ディレクトリ構造
上記のレイヤードアーキテクチャを、具体的なディレクトリ構造に落とし込むと以下のようになります。
lib
├── application
│ ├── provider # UsecaseにRepositoryの具象実装をDIする
│ └── usecase # Usecaseの具象実装
├── constant # アプリケーションで使用する定数
├── core # 型の拡張やユーティリティ
├── domain
│ ├── entity # ビジネスエンティティ (Freezed)
│ ├── repository # Repositoryのインターフェース
│ ├── usecase # Usecaseのインターフェース
│ ├── service # ビジネスの中核を担うロジックをサービスとして提供
│ └── value # 値オブジェクト (Enumなど)
├── infrastructure
│ ├── database # データベースやAPIとの接続や操作
│ ├── model # データソースが使用するモデル (DTO)
│ ├── provider # RepositoryなどをDIするためのProvider
│ ├── service # OSの機能やセンサ、フレームワークに関するサービスとして提供
│ └── repository # Repositoryの具象実装
└── ui
├── component # 画面間で再利用するWidget
├── notifier # UIの状態を管理するViewModel (StateNotifier)
├── page # 各画面のWidget
├── state # UIの状態を表すクラス (Freezed)
└── navigator # 画面遷移やダイアログ表示
データフロー
各レイヤーで扱うデータモデルは、その責務に応じて明確に分離されます。
先ほどのアーキテクチャの全体像を示す依存性の関係を表現した図をもとに、各レイヤーで使用するモデルとその変換を表した図を以下に示します。

-
Infrastructure Layer
Repositoryの実装は、DriftやFirebaseなどの外部ライブラリが扱うテーブルの型やライブラリが提供する型を、Domain層で定義されたEntityへと変換します。 -
Domain/Application Layer
これらの層では、Entityのみを扱います。ビジネスロジックは、インフラの都合を一切気にする必要がありません。 -
UI Layer
Notifier(ViewModel)は、Application層から受け取ったEntityを、画面表示に最適化されたStateオブジェクトに変換します。
この変換の連鎖こそが、各レイヤーの独立性を保ち、変更に強い柔軟なアプリケーションを実現する鍵となります。
コードサンプルと各レイヤーの詳解
importしているファイルのディレクトリに注目すると、アーキテクチャの依存関係がわかりやすいと思います。
Domain
ここはアプリケーションの「不変の中心」となる層であり、
アプリケーションが提供するビジネスロジック(何をしたいのか)を定義します。
Entity
Freezedで定義したビジネスエンティティ
import 'package:clean_architecture_todo/core/converter/timestamp_converter.dart';
import 'package:clean_architecture_todo/domain/value/priority.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'task.freezed.dart';
part 'task.g.dart';
@freezed
abstract class Task with _$Task {
const factory Task({
required String id,
required String title,
required String description,
required bool isCompleted,
@TimestampConverter() required DateTime createdAt,
@TimestampConverter() required DateTime dueDate,
required Priority priority,
}) = _Task;
factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);
}
Repository
Infrastructure層で実装される「データ取得のインターフェース」
import 'package:clean_architecture_todo/domain/entity/task.dart';
abstract class ITaskRepository {
Stream<List<Task>> watchTasks();
Future<Task?> getTaskById(String taskId);
Future<void> addTask(Task task);
Future<void> updateTask(Task task);
Future<void> removeTask(String taskId);
}
UseCase
アプリケーションが提供する機能(振る舞い)を定義する
import 'package:clean_architecture_todo/domain/value/priority.dart';
abstract class IAddTaskUseCase {
Future<void> execute({
required String title,
required String description,
required DateTime dueDate,
required Priority priority,
});
}
Service
変化しないビジネスルール(例: 固有のロジック、課金の条件など)を純粋関数で定義
import 'package:clean_architecture_todo/domain/entity/task.dart';
class TaskOverdueService {
bool isOverdue(Task task) {
if (task.isCompleted) {
return false;
}
final now = DateTime.now();
return task.dueDate.isBefore(now);
}
}
Value
Entityで使用する値やEnumを定義
enum Priority {
none,
low,
medium,
high,
}
Application
UseCaseの具体的な実装を行う層です。
Domain層が定義したIRepositoryを使って機能を実装します。
Provider
UseCaseにRepositoryの具象クラスをDIする
import 'package:clean_architecture_todo/application/usecase/add_task_usecase.dart';
import 'package:clean_architecture_todo/application/usecase/remove_task_usecase.dart';
import 'package:clean_architecture_todo/application/usecase/toggle_task_completion_usecase.dart';
import 'package:clean_architecture_todo/application/usecase/update_task_usecase.dart';
import 'package:clean_architecture_todo/application/usecase/watch_tasks_usecase.dart';
import 'package:clean_architecture_todo/domain/usecase/add_task_usecase.dart';
import 'package:clean_architecture_todo/domain/usecase/remove_task_usecase.dart';
import 'package:clean_architecture_todo/domain/usecase/toggle_task_completion_usecase.dart';
import 'package:clean_architecture_todo/domain/usecase/update_task_usecase.dart';
import 'package:clean_architecture_todo/domain/usecase/watch_tasks_usecase.dart';
import 'package:clean_architecture_todo/infrastructure/provider/task_repository_provider.dart';
import 'package:clean_architecture_todo/infrastructure/provider/vibration_service_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'task_usecase_providers.g.dart';
@riverpod
IWatchTasksUseCase watchTasksUseCase(Ref ref) {
final repository = ref.watch(taskRepositoryProvider);
return WatchTasksUseCase(repository);
}
@riverpod
IAddTaskUseCase addTaskUseCase(Ref ref) {
final repository = ref.watch(taskRepositoryProvider);
return AddTaskUseCase(repository);
}
@riverpod
IUpdateTaskUseCase updateTaskUseCase(Ref ref) {
final repository = ref.watch(taskRepositoryProvider);
return UpdateTaskUseCase(repository);
}
@riverpod
IRemoveTaskUseCase removeTaskUseCase(Ref ref) {
final repository = ref.watch(taskRepositoryProvider);
return RemoveTaskUseCase(repository);
}
@riverpod
IToggleTaskCompletionUseCase toggleTaskCompletionUseCase(Ref ref) {
final repository = ref.watch(taskRepositoryProvider);
final vibrationService = ref.watch(vibrationServiceProvider);
return ToggleTaskCompletionUseCase(repository, vibrationService);
}
UseCase
アプリの具体的な機能を実装します。
importに注目すると、基本的にはdomain層にのみ依存していることがわかります。
import 'package:clean_architecture_todo/domain/entity/task.dart';
import 'package:clean_architecture_todo/domain/repository/task_repository.dart';
import 'package:clean_architecture_todo/domain/usecase/add_task_usecase.dart';
import 'package:clean_architecture_todo/domain/value/priority.dart';
import 'package:uuid/uuid.dart';
class AddTaskUseCase implements IAddTaskUseCase {
final ITaskRepository _repository;
AddTaskUseCase(this._repository);
@override
Future<void> execute({
required String title,
required String description,
required DateTime dueDate,
required Priority priority,
}) {
final newTask = Task(
id: const Uuid().v4(),
title: title,
description: description,
isCompleted: false,
createdAt: DateTime.now(),
dueDate: dueDate,
priority: priority,
);
return _repository.addTask(newTask);
}
}
Infrastructure
Firebase / Drift / API などの外部データソースと接続し、Domain 層の IRepository を実装する層です。
DataSource
Firebase, Drift, APIなどを扱う。
ライブラリが提供するデータモデルやModelで定義した型を使用する。
import 'package:clean_architecture_todo/infrastructure/datasource/app_database.dart';
import 'package:clean_architecture_todo/infrastructure/model/task_table.dart';
import 'package:drift/drift.dart';
part 'task_dao.g.dart';
@DriftAccessor(tables: [Tasks])
class TaskDao extends DatabaseAccessor<AppDatabase> with _$TaskDaoMixin {
TaskDao(super.db);
Stream<List<Task>> watchTasks() => select(tasks).watch();
Future<Task?> getTaskById(String id) =>
(select(tasks)..where((t) => t.id.equals(id))).getSingleOrNull();
Future<void> upsertTask(TasksCompanion task) =>
into(tasks).insertOnConflictUpdate(task);
Future<void> deleteTask(String id) =>
(delete(tasks)..where((t) => t.id.equals(id))).go();
}
Model
DBのテーブルや、APIのリクエストやレスポンスの定義
import 'package:clean_architecture_todo/infrastructure/database/app_database.dart';
import 'package:drift/drift.dart';
@DataClassName('Task')
class Tasks extends Table {
TextColumn get id => text()();
TextColumn get title => text().withLength(min: 1, max: 50)();
TextColumn get description => text()();
BoolColumn get isCompleted => boolean().withDefault(const Constant(false))();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get dueDate => dateTime()();
IntColumn get priority => integer().map(const PriorityConverter())();
@override
Set<Column> get primaryKey => {id};
}
Provider
RepositoryにDataSourceをDIする
import 'package:clean_architecture_todo/domain/repository/task_repository.dart';
import 'package:clean_architecture_todo/infrastructure/database/database_provider.dart';
import 'package:clean_architecture_todo/infrastructure/repository/task_repository_impl.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'task_repository_provider.g.dart';
@riverpod
ITaskRepository taskRepository(Ref ref) {
final taskDao = ref.watch(taskDaoProvider);
return TaskRepositoryImpl(taskDao: taskDao);
}
Service
センサ(例: GPS)や端末の機能(例: マイク、スピーカー)、Method ChannelなどDBやAPI以外のリソースで、サービスとして提供できると考えられるものをここで実装する。
import 'package:clean_architecture_todo/domain/service/vibration_service.dart';
import 'package:vibration/vibration.dart';
class VibrationServiceImpl implements IVibrationService {
@override
Future<void> vibrate() async {
final hasVibrator = await Vibration.hasVibrator();
if (hasVibrator) {
Vibration.vibrate(duration: 100);
}
}
}
Repository
DataSourceやServiceにアクセスして、アプリで使用するEntityに変化させて返す。
import 'package:clean_architecture_todo/domain/entity/task.dart' as domain_task;
import 'package:clean_architecture_todo/domain/repository/task_repository.dart';
import 'package:clean_architecture_todo/infrastructure/database/app_database.dart' as drift;
import 'package:clean_architecture_todo/infrastructure/database/task_dao.dart';
import 'package:drift/drift.dart';
class TaskRepositoryImpl implements ITaskRepository {
TaskRepositoryImpl({required this.taskDao});
final TaskDao taskDao;
@override
Stream<List<domain_task.Task>> watchTasks() {
return taskDao.watchTasks().map(
(driftTasks) => driftTasks
.map(
(driftTask) => _toDomainTask(driftTask),
)
.toList(),
);
}
@override
Future<domain_task.Task?> getTaskById(String taskId) async {
final driftTask = await taskDao.getTaskById(taskId);
return driftTask != null ? _toDomainTask(driftTask) : null;
}
@override
Future<void> addTask(domain_task.Task task) {
return taskDao.upsertTask(_fromDomainTask(task));
}
@override
Future<void> updateTask(domain_task.Task task) {
return taskDao.upsertTask(_fromDomainTask(task));
}
@override
Future<void> removeTask(String taskId) {
return taskDao.deleteTask(taskId);
}
domain_task.Task _toDomainTask(drift.Task driftTask) {
return domain_task.Task(
id: driftTask.id,
title: driftTask.title,
description: driftTask.description,
isCompleted: driftTask.isCompleted,
createdAt: driftTask.createdAt,
dueDate: driftTask.dueDate,
priority: driftTask.priority,
);
}
drift.TasksCompanion _fromDomainTask(domain_task.Task domainTask) {
return drift.TasksCompanion(
id: Value(domainTask.id),
title: Value(domainTask.title),
description: Value(domainTask.description),
isCompleted: Value(domainTask.isCompleted),
createdAt: Value(domainTask.createdAt),
dueDate: Value(domainTask.dueDate),
priority: Value(domainTask.priority),
);
}
}
UI
ユーザーとのインタラクションを担当します。
Component
画面間で再利用するパーツとなるWidget
以下のクラスではViewModelに依存していますが、汎用コンポーネントの場合は引数などでコールバックを受け取るようにすると、より疎結合になると思います。
import 'package:clean_architecture_todo/ui/navigator/navigator.dart';
import 'package:clean_architecture_todo/ui/notifier/task_list_view_model.dart';
import 'package:clean_architecture_todo/ui/state/task_list_state.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class TaskListItem extends ConsumerWidget {
const TaskListItem({
super.key,
required this.task,
});
final TaskUiModel task;
@override
Widget build(BuildContext context, WidgetRef ref) {
return CheckboxListTile(
title: Text(
task.title,
style: TextStyle(
decoration: task.isCompleted ? TextDecoration.lineThrough : TextDecoration.none,
),
),
subtitle: Text(
'期限: ${task.dueDate}',
style: TextStyle(
color: task.isOverdue ? Colors.red : null,
),
),
value: task.isCompleted,
onChanged: (value) {
ref.read(taskListViewModelProvider.notifier).toggleCompletion(task.id);
},
secondary: IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
ref.read(navigatorProvider).showDeleteConfirmDialog(
title: 'タスクの削除',
content: '「${task.title}」を削除しますか?',
onConfirm: () {
ref.read(taskListViewModelProvider.notifier).removeTask(task.id);
},
);
},
),
);
}
}
Notifier
画面の状態を管理するViewModel
Notifierディレクトリではなく、ViewModelディレクトリとしてもいいと思います。
importに注目すると、UseCaseやRepositoryの実装には全く依存していないことがわかります。
import 'package:clean_architecture_todo/application/provider/task_usecase_providers.dart';
import 'package:clean_architecture_todo/domain/service/task_overdue_service.dart';
import 'package:clean_architecture_todo/domain/usecase/remove_task_usecase.dart';
import 'package:clean_architecture_todo/domain/usecase/toggle_task_completion_usecase.dart';
import 'package:clean_architecture_todo/ui/state/task_list_state.dart';
import 'package:intl/intl.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'task_list_view_model.g.dart';
@riverpod
class TaskListViewModel extends _$TaskListViewModel {
late IRemoveTaskUseCase _removeTaskUseCase;
late IToggleTaskCompletionUseCase _toggleTaskCompletionUseCase;
late TaskOverdueService _taskOverdueService;
late DateFormat _dateFormat;
@override
TaskListState build() {
_loadUseCases();
_watchTasks();
return const TaskListState();
}
void _loadUseCases() {
_removeTaskUseCase = ref.watch(removeTaskUseCaseProvider);
_toggleTaskCompletionUseCase = ref.watch(
toggleTaskCompletionUseCaseProvider,
);
_taskOverdueService = TaskOverdueService();
_dateFormat = DateFormat('yyyy-MM-dd');
}
void _watchTasks() {
final watchTasksUseCase = ref.watch(watchTasksUseCaseProvider);
watchTasksUseCase.execute().listen((tasks) {
final taskUiModels = tasks
.map(
(task) => TaskUiModel(
id: task.id,
title: task.title,
description: task.description,
isCompleted: task.isCompleted,
dueDate: _dateFormat.format(task.dueDate),
priority: task.priority.name,
isOverdue: _taskOverdueService.isOverdue(task),
),
)
.toList();
state = state.copyWith(tasks: taskUiModels);
});
}
Future<void> removeTask(String taskId) async {
await _removeTaskUseCase.execute(taskId);
}
Future<void> toggleCompletion(String taskId) async {
await _toggleTaskCompletionUseCase.execute(taskId);
}
}
Page
画面となるWidget
import 'package:clean_architecture_todo/ui/component/task_list_item.dart';
import 'package:clean_architecture_todo/ui/notifier/task_list_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class TaskListPage extends ConsumerWidget {
const TaskListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(taskListViewModelProvider);
return state.tasks.isEmpty
? const Center(child: Text('タスクがありません', style: TextStyle(fontSize: 18)))
: ListView.builder(
itemCount: state.tasks.length,
itemBuilder: (context, index) {
final task = state.tasks[index];
return TaskListItem(task: task);
},
);
}
}
State
PageやComponentの状態を表すデータモデル
import 'package:freezed_annotation/freezed_annotation.dart';
part 'task_list_state.freezed.dart';
@freezed
abstract class TaskListState with _$TaskListState {
const factory TaskListState({
@Default([]) List<TaskUiModel> tasks,
}) = _TaskListState;
}
@freezed
abstract class TaskUiModel with _$TaskUiModel {
const factory TaskUiModel({
required String id,
required String title,
required String description,
required bool isCompleted,
required String dueDate,
required String priority,
required bool isOverdue,
}) = _TaskUiModel;
}
Navigator
ダイアログ表示や画面遷移のアニメーションを管理
import 'package:clean_architecture_todo/ui/page/add_task_page.dart';
import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'navigator.g.dart';
@riverpod
Navigator navigator(Ref ref) {
return Navigator();
}
class Navigator {
final key = GlobalKey<NavigatorState>();
BuildContext get _context => key.currentContext!;
Future<void> showDeleteConfirmDialog({
required String title,
required String content,
required VoidCallback onConfirm,
}) async {
return showDialog(
context: _context,
builder: (context) {
return AlertDialog(
title: Text(title),
content: Text(content),
actions: [
TextButton(
onPressed: () => key.currentState!.pop(),
child: const Text('キャンセル'),
),
TextButton(
onPressed: () {
onConfirm();
key.currentState!.pop();
},
child: const Text('削除'),
),
],
);
},
);
}
Future<void> pushToAddPage() async {
await key.currentState!.push(
MaterialPageRoute(
builder: (context) => AddTaskPage(),
),
);
}
void pop() {
key.currentState!.pop();
}
}
その他
Core
utilディレクトリとextensionディレクトリを用意するといいと思います。
Constant
マジックナンバーをなくすために、UIに関わる定数を配置したりするといいと思います。
おわりに
本記事では、Flutterアプリケーションのための実践的なクリーンアーキテクチャの一例を紹介しました。
この設計のメリットは以下の通りです。
- 高い保守性: 各レイヤーの責務が明確なため、コードのどこに何が書かれているか把握しやすくなります。
- 高いテスト容易性: 依存関係がインターフェースを通じて管理されているため、単体テストが容易になります。
- 柔軟性: データベースや外部APIといった技術的な詳細を、ビジネスロジックから切り離して変更・交換できます。
もちろん、このアーキテクチャが唯一の正解ではありません。しかし、アプリケーションが成長するにつれて「どこに手をつければよいかわからない」という状況を未然に防ぐための一助となれば幸いです。
