㊙️

【Flutter】Clean Architectureに基づいたディレクトリ構成

2023/03/13に公開

はじめに

最近Flutterで開発をする際、クリーンアーキテクチャに基づいたディレクトリ構成を採用しているのですが、個人的に一番使いやすい布陣ができたので、折角なので紹介したいと思います。

直接コードを見たほうがわかりやすい人もいるかもしれないので、先に貼っておきます。
https://github.com/f-nakahara/flutter-clean-architecture

前提

  • クリーンアーキテクチャの基礎を理解している
  • Flutter: 3.3.10
  • Dart: 2.18.6

ディレクトリ構成

lib/
  ├── core/
  │   ├── exception/
  │   ├── extension/
  │   └── util/
  ├── domain/
  │   ├── entity/
  │   ├── value/
  │   ├── repository/
  │   ├── usecase/
  │   └── factory/
  ├── application/
  │   └── usecase/
  ├── infrastructure/
  │   ├── datasource/
  │   ├── repository/
  │   └── factory/
  ├── presentation/
  │   ├── component/
  │   ├── page/
  │   ├── notifier/
  │   ├── state/
  │   └── navigation/
  └── main.dart

かなり雑な図ですが、各コンポーネント間のデータの流れを示しました。
上から順に、呼び出しを行い、取得したデータが下から上へと流れているつもりです。

core

coreディレクトリには、各コンポーネントで共通して使用されるコード等を配置します。

本記事では、以下のディレクトリを作成しました。

  • exception
  • extension
  • util

上記の各ディレクトリに作成されるサンプルコードは次のようなものがあります。

core/exception

ここには、例外やエラーを定義します。
例外や、エラーの種類ごとにファイルを作成します。

network_exception.dart
class NetworkException implements Exception {
  final String message;

  NetworkException(this.message);

  
  String toString() {
    return 'NetworkException: $message';
  }
}

core/extension

ここには、拡張関数を定義します。
拡張元の種類ごとにファイルを作成します。

string_extension.dart
extension StringExtension on String {

  /// 文字列がメールアドレスであるか判定する
  bool get isEmail {
    return RegExp(r'^.+@[a-zA-Z]+\.[a-zA-Z]+(\.?[a-zA-Z]+)$').hasMatch(this);
  }
}

core/util

ここには、便利メソッドを定義します(この表現は果たして正しいのだろうか。。。)
utilの特性として、クラスメソッドはすべてstaticとして定義します。

logger_util.dart
class LoggerUtil {
  LoggerUtil._();
  
  /// コンソール上にログを表示する 
  static void log(String message) {
    debugPrint('[Logger] $message');
  }
}

Domain

Domain層には、以下のディレクトリが含まれています。

  • entity: アプリ共通のモデル定義
  • value: ドメインエンティティで使用するバリューオブジェクト
  • repository: 外部リソースアクセスの仲介インターフェースの定義
  • usecase: アプリケーションロジックインターフェースの定義
  • factory: ドメインエンティティ生成インターフェースの定義

domain/entity

ここには、ドメインエンティティを定義します。

user.dart
/// ユーザー
class User {
  final String id; // 識別ID
  final String name; // 名前
  final UserGender gender; // 性別
  final String thumbnail; // サムネイルリンク
  final DateTime birthday; // 生年月日

  User({
    required this.id,
    required this.name,
    required this.gender,
    required this.thumbnail,
    required this.birthday,
  });

  /// [User]が誕生日であるか判定する
  bool isBirthday() {
    final today = DateTime.now();
    return birthday.month == today.month && birthday.day == today.day;
  }
}

domain/value

ここには、ドメインエンティティで使用するバリューオブジェクトを定義します。

user_gender.dart
/// ユーザーの性別
enum UserGender {
  male, // 男性
  female, // 女性
  other, // その他
}

domain/repository

ここには、外部リソースにアクセスするためのレポジトリのインターフェースを定義します。

これにより、使用者はどこのAPIからデータを取得しているのか、どこのデータベースから取得しているのかを意識することなくデータを取得することができます。

user_repository.dart
abstract class UserRepository {
  /// ユーザー一覧の取得
  Future<List<User>> findAll();
}

domain/usecase

ここには、アプリケーションの機能に関するユースケースのインターフェースを定義します。

ユーザー一覧を取得するといったアプリケーションで実現化する機能ごとに作成します。

get_users_usecase.dart
/// ユーザー一覧を取得する
abstract class GetUsersUsecase {
  Future<List<User>> execute();
}

domain/factory

ここは、エンティティを生成するためのインターフェースを定義します。

他のサイトで紹介されているクリーンアーキテクチャを基にしたディレクトリ構成では、datasourcemodelentityを継承したり、toEntity()メソッドを実装して変換を行っていますが、実際にエンティティを構築する際は、複数のdatasourceを使用しないと構築できない場合があるため、factoryというものを作成しました。

個人的に割と神分離できたかなと勝手に満足しています。

user_factory.darty
abstract class UserFactory {
  /// [User]を生成する
  User create({
    required String name,
    required String gender,
    required String thumbnail,
    required DateTime birthday,
  });
  
  /// [RugUser]から[User]を生成する
  User createFromModel(RugUser user);
}
user_gender_factory.dart
abstract class UserGenderFactory {
  /// [UserGender]を生成する
  UserGender create(String value);

  /// [RugUserGender]から[UserGender]を生成する
  UserGender createFromModel(RugUserGender gender);
}

RugUser, RugUserGenderとありますが、こちらは後で説明するinfrastructure/datasourceで使用するmodelを表してます。

Application

Application層には以下のディレクトリが含まれます。

  • usecase: ドメイン層で定義したアプリケーションロジックインターフェースの実装

application/usecase

ここには、domain/usecaseで定義したインターフェースの実装を書きます。
usecaseにはrepositoryを注入し、それを使用するようにします。
注意として、クリーンアーキテクチャの依存関係から、datasourcefactoryをここでは使用してはいけません。

今回は、単純にレポジトリの戻り値をそのまま返しているだけですが、アプリケーションロジックを書く場所であるため、実際にはもっと長くなるケースが多いです。

get_users_usecase_impl.dart
class GetUsersUsecaseImpl implements GetUsersUsecase {
  final UserRepository _userRepository;

  GetUsersUsecaseImpl({
    required UserRepository userRepository,
  }) : _userRepository = userRepository;

  
  Future<List<User>> execute() async {
    return await _userRepository.findAll();
  }
}

Infrastructure

Infrastructure層には以下のディレクトリが含まれます。

  • datasource: 実際に外部リソースとやりとりするインターフェースの定義と実装
  • model: データソースモデル定義
  • repository: ドメイン層で定義したrepositoryの実装
  • factory: ドメイン層で定義したfactoryの実装

infrastructure/datasource

ここには、実際に外部リソースにアクセスするための、インターフェースの定義と実装をしていきます。
今回はrandom user generatorというフリーAPIで例を示しています。

random_user_generator_api_datasource.dart
/// https://randomuser.me
abstract class RandomUserGeneratorApiDatasource {
  Future<RugGetUsersResponse> getUsers({int results});
}
random_user_generator_api_datasource_impl.dart
class RandomUserGeneratorApiDatasourceImpl
    implements RandomUserGeneratorApiDatasource {
  final Dio _dio;

  RandomUserGeneratorApiDatasourceImpl({required Dio dio}) : _dio = dio;

  
  Future<RugGetUsersResponse> getUsers({int results = 10}) async {
    final res = await _dio.get(
      '/',
      queryParameters: {
        'results': results,
      },
    );
    if (res.statusCode == 200) {
      final body = res.data;
      final data = RugGetUsersResponse.fromJson(body);
      return data;
    } else {
      throw NetworkException('RandomUserGeneratorApiImpl getUsers() "/"');
    }
  }
}

infrastructure/model

ここには、データソースで使用するモデルを定義します。
APIやデータベースの構成と同じ構成で定義します。
今回はrandom user generatorというフリーAPIのレスポンスで例を示しています。
接頭辞のRugというのは、どのデータソースのモデルかを分かりやすくするためのもので、今回の場合は、Random User Generator の各先頭の文字を組み合わせました。
接頭辞がなくても、問題ない方は別につけなくても大丈夫です。

get_users_response.dart
class RugGetUsersResponse {
  final List<RugUser> results;
  final RugGetUsersResponseInfo info;

  RugGetUsersResponse({
    required this.info,
    required this.results,
  });

  factory RugGetUsersResponse.fromJson(Map<String, dynamic> json) {
    return RugGetUsersResponse(
      info: RugGetUsersResponseInfo.fromJson(json['info']),
      results:
          json['results'].map<RugUser>((e) => RugUser.fromJson(e)).toList(),
    );
  }
}
user.dart
class RugUser {
  final RugUserGender gender;
  final RugUserName name;
  final RugUserLogin login;
  final RugUserPicture picture;
  final RugUserDob dob;

  RugUser({
    required this.gender,
    required this.name,
    required this.login,
    required this.picture,
    required this.dob,
  });

  factory RugUser.fromJson(json) {
    return RugUser(
      gender: RugUserGender.from(json['gender']),
      name: RugUserName.fromJson(json['name']),
      login: RugUserLogin.fromJson(json['login']),
      picture: RugUserPicture.fromJson(json['picture']),
      dob: RugUserDob.fromJson(json['dob']),
    );
  }
}

infrastructure/repository

ここには、domain/repositoryで定義したインターフェースの実装を行います。
datasourcefactoryconstructor()から注入して使用するようにします。
datasourceから受け取ったmodelからentityに変換する際はfactoryを使用します。

user_repository_impl.dart
class UserRepositoryImpl implements UserRepository {
  final RandomUserGeneratorApi _randomUserGeneratorApi;
  final UserFactory _userFactory;

  UserRepositoryImpl({
    required RandomUserGeneratorApi randomUserGeneratorApi,
    required UserFactory userFactory,
  })  : _randomUserGeneratorApi = randomUserGeneratorApi,
        _userFactory = userFactory;

  
  Future<List<User>> findAll() async {
    try {
      final res = await _randomUserGeneratorApi.getUsers();
      return res.results
          .map(
            (e) => _userFactory.createFromModel(e),
          )
          .toList();
    } catch (e) {
      rethrow;
    }
  }
}

infrastructure/factory

ここには、domain/factoryで定義したインターフェースの実装を行います。
idや今回は使用していませんが、createdAt,updatedAtなど、初回生成時に決定するような値はここで生成するようにします。

user_factory_impl.dart
class UserFactoryImpl implements UserFactory {
  final UserGenderFactory _genderFactory;

  UserFactoryImpl({
    required UserGenderFactory genderFactory,
  }) : _genderFactory = genderFactory;
  
  
  User create({
    required String name,
    required String gender,
    required String thumbnail,
    required DateTime birthday,
  }) {
    return User(
      id: '12345', // uuid等で自動で生成する
      name: name,
      gender: _genderFactory.create(gender),
      thumbnail: thumbnail,
      birthday: birthday,
    );
  }

  
  User createFromModel(RugUser user) {
    return User(
      id: user.login.uuid,
      name: '${user.name.title} ${user.name.first} ${user.name.last}',
      gender: _genderFactory.createFromModel(user.gender),
      thumbnail: user.picture.thumbnail,
      birthday: user.dob.date,
    );
  }
}
user_gender_factory_impl.dart
class UserGenderFactoryImpl implements UserGenderFactory {
  
  UserGender create(String value) {
    switch (value) {
      case 'male':
        return UserGender.male;
      case 'female':
        return UserGender.female;
      case 'other':
        return UserGender.other;
      default:
        throw ArgumentError();
    }
  }

  
  UserGender createFromModel(RugUserGender gender) {
    switch (gender) {
      case RugUserGender.male:
        return UserGender.male;
      case RugUserGender.female:
        return UserGender.female;
      default:
        return UserGender.other;
    }
  }
}

Presentation

Presentation層には以下のディレクトリが含まれます。

  • component: 画面に表示する部品ごとのウィジェットを定義
  • page: 画面
  • notifier: アプリケーション層との仲介役、状態管理等
  • state: 画面に表示するモデル定義
  • navigation: 画面遷移等のメソッドをラップ

presentation/component

ここには、画面を構成する際の部品ウィジェットを定義します。

user_list.dart
class UserList extends ConsumerWidget {
  const UserList({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(userListNotifierProvider);
    return state.when(
      data: (users) {
        return ListView(
          children: users
              .map(
                (e) => UserLiteItem(
                    birthday: e.birthday,
                    name: e.name,
                    thumbnailLink: e.thumbnailLink),
              )
              .toList(),
        );
      },
      error: (error, _) {
        return Center(
          child: Text(
            error.toString(),
          ),
        );
      },
      loading: () {
        return const Center(
          child: CircularProgressIndicator(),
        );
      },
    );
  }
}

presentation/page

ここには、各画面を定義します。

home_page.dart
class HomePage extends StatelessWidget {
  const HomePage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return const Scaffold(
      body: SafeArea(
        child: UserList(),
      ),
    );
  }
}

presentation/notifier

ここには、StateNotifierを継承したクラスを定義します。

user_list_notifier.dart
class UserListNotifier extends StateNotifier<AsyncValue<List<UserState>>> {
  UserListNotifier({required GetUsersUsecase getUsersUsecase})
      : _getUsersUsecase = getUsersUsecase,
        super(const AsyncLoading()) {
    _fetch();
  }

  final GetUsersUsecase _getUsersUsecase;

  /// ユーザー一覧の同期
  Future<void> _fetch() async {
    state = await AsyncValue.guard(() async {
      final res = await _getUsersUsecase.execute();
      return res.map((e) => UserState.fromEntity(e)).toList();
    });
  }
}

presentation/state

ここには、画面に表示するモデルや、ユーザーから受け取る情報等を定義します。
特徴として、画面に表示する内容そのものを定義するため、DateTime型を表示したい形式のString型に変換して生成します。

user_state.dart

class UserState with _$UserState {
  factory UserState({
    required String name,
    required String thumbnailLink,
    required String birthday,
  }) = _UserState;

  factory UserState.fromEntity(User user) {
    return UserState(
      name: user.name,
      thumbnailLink: user.thumbnail,
      birthday:
          '${user.birthday.year}${user.birthday.month}${user.birthday.day}日',
    );
  }
}

presentation/navigation

ここには、画面遷移用のNavigatorshowAlertDialogをラップしたものを定義します。
ここでラップすることにより、viewへのコード削減や、遷移時のアニメーションの統一をすることができます。

page_navigation.dart
class PageNavigation {
  static Future push(BuildContext context, Widget page) async {
    return Navigator.of(context).push(
      MaterialPageRoute(
        builder: (context) {
          return page;
        },
      ),
    );
  }
}

さいごに

ここで紹介しているサンプルコードには、Providerを定義している部分は載せていませんが、個人的にはinterfacenotifierを定義しているファイルに書いています。
他の記述箇所の候補としては、coreディレクトリ内に、providerディレクトリを作成し、その中で定義していくのもいいのかなと思ってます。

https://github.com/f-nakahara/flutter-clean-architecture

Discussion