19
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Flutter】クリーンアーキテクチャを採用したディレクトリ構成

Last updated at Posted at 2025-12-12

はじめに

この記事は Life is Tech ! Advent Calendar 2025 の記事です。はるっぺです。

個人的に一番納得感のある、クリーンアーキテクチャを採用したFlutterのディレクトリ構造を考えついたので、これをまとめていきます。

このアーキテクチャと記事はAIと一緒に考えてAIと一緒に書いています。
なんとなくの理解なため、クリーンアーキテクチャの詳しい解説は一次ソースを当たっていただきたいです。

Flutterで中規模〜大規模アプリを作ると、多くの開発者が直面するのが

  • 肥大化するUIロジック
  • Repository の責務の不明確さ
  • データの流れがわかりにくくなる
    といった問題です。

これらを解決するためのアプローチがクリーンアーキテクチャです。

本記事では、

  • MVVM
  • Repository パターン
  • クリーンアーキテクチャ

を統合した、実践的なアーキテクチャ例を紹介します。

特定の理論に厳密に従うのではなく、可読性と変更容易性を最優先にした構造です。

ここで紹介するアーキテクチャは、以下のリポジトリで実装されているTODOアプリに基づいています。具体的なコードと合わせて読むことで、より深い理解が得られるはずです。(まだまだ更新中です)

また、本記事の設計思想は、以下の書籍や記事、リポジトリから多大な影響を受けています。先人たちの知見に感謝します。

目指すアーキテクチャの全体像

本稿で提案するアーキテクチャは、関心の分離を徹底するためのレイヤードアーキテクチャです。各レイヤーは決められた方向にのみ依存し、ビジネスロジックの純粋性とUIやインフラとの独立性を高めます。

全体のシステム構成と依存性の流れは以下のようになります。

依存図.jpg

  • 黒色の矢印:
    コードレベルでの直接的な依存関係(呼び出しや実装)を示します。例えば、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層のRepositoryDatasourceに依存していますが、厳密に行う場合はここにも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     # 画面遷移やダイアログ表示

データフロー

各レイヤーで扱うデータモデルは、その責務に応じて明確に分離されます。

先ほどのアーキテクチャの全体像を示す依存性の関係を表現した図をもとに、各レイヤーで使用するモデルとその変換を表した図を以下に示します。
型変換.jpg

  1. Infrastructure Layer
    Repositoryの実装は、DriftやFirebaseなどの外部ライブラリが扱うテーブルの型やライブラリが提供する型を、Domain層で定義されたEntityへと変換します。

  2. Domain/Application Layer
    これらの層では、Entityのみを扱います。ビジネスロジックは、インフラの都合を一切気にする必要がありません。

  3. UI Layer
    Notifier(ViewModel)は、Application層から受け取ったEntityを、画面表示に最適化されたStateオブジェクトに変換します。

この変換の連鎖こそが、各レイヤーの独立性を保ち、変更に強い柔軟なアプリケーションを実現する鍵となります。

コードサンプルと各レイヤーの詳解

importしているファイルのディレクトリに注目すると、アーキテクチャの依存関係がわかりやすいと思います。

Domain

ここはアプリケーションの「不変の中心」となる層であり、
アプリケーションが提供するビジネスロジック(何をしたいのか)を定義します。

Entity

Freezedで定義したビジネスエンティティ

task.dart
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層で実装される「データ取得のインターフェース」

task_repository.dart
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

アプリケーションが提供する機能(振る舞い)を定義する

add_task_usecase.dart
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

変化しないビジネスルール(例: 固有のロジック、課金の条件など)を純粋関数で定義

task_overdue_service.dart
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を定義

priority.dart
enum Priority {
  none,
  low,
  medium,
  high,
}

Application

UseCaseの具体的な実装を行う層です。
Domain層が定義したIRepositoryを使って機能を実装します。

Provider

UseCaseRepositoryの具象クラスをDIする

task_usecase_providers.dart
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層にのみ依存していることがわかります。

add_task_usecase.dart
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で定義した型を使用する。

task_dao.dart
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のリクエストやレスポンスの定義

task_table.dart
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

RepositoryDataSourceをDIする

task_repository_provider.dart
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以外のリソースで、サービスとして提供できると考えられるものをここで実装する。

vibration_service_impl.dart
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

DataSourceServiceにアクセスして、アプリで使用するEntityに変化させて返す。

task_repository_impl.dart
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に依存していますが、汎用コンポーネントの場合は引数などでコールバックを受け取るようにすると、より疎結合になると思います。

task_list_item.dart
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に注目すると、UseCaseRepositoryの実装には全く依存していないことがわかります。

task_list_view_model.dart
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

task_list_page.dart
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

PageComponentの状態を表すデータモデル

task_list_state.dart
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

ダイアログ表示や画面遷移のアニメーションを管理

navigator.dart
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といった技術的な詳細を、ビジネスロジックから切り離して変更・交換できます。

もちろん、このアーキテクチャが唯一の正解ではありません。しかし、アプリケーションが成長するにつれて「どこに手をつければよいかわからない」という状況を未然に防ぐための一助となれば幸いです。

19
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?