【Flutter】Clean Architectureに基づいたディレクトリ構成
はじめに
最近Flutterで開発をする際、クリーンアーキテクチャに基づいたディレクトリ構成を採用しているのですが、個人的に一番使いやすい布陣ができたので、折角なので紹介したいと思います。
直接コードを見たほうがわかりやすい人もいるかもしれないので、先に貼っておきます。
前提
- クリーンアーキテクチャの基礎を理解している
- 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
ここには、例外やエラーを定義します。
例外や、エラーの種類ごとにファイルを作成します。
class NetworkException implements Exception {
final String message;
NetworkException(this.message);
String toString() {
return 'NetworkException: $message';
}
}
core/extension
ここには、拡張関数を定義します。
拡張元の種類ごとにファイルを作成します。
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
として定義します。
class LoggerUtil {
LoggerUtil._();
/// コンソール上にログを表示する
static void log(String message) {
debugPrint('[Logger] $message');
}
}
Domain
Domain層には、以下のディレクトリが含まれています。
-
entity
: アプリ共通のモデル定義 -
value
: ドメインエンティティで使用するバリューオブジェクト -
repository
: 外部リソースアクセスの仲介インターフェースの定義 -
usecase
: アプリケーションロジックインターフェースの定義 -
factory
: ドメインエンティティ生成インターフェースの定義
domain/entity
ここには、ドメインエンティティを定義します。
/// ユーザー
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
ここには、ドメインエンティティで使用するバリューオブジェクトを定義します。
/// ユーザーの性別
enum UserGender {
male, // 男性
female, // 女性
other, // その他
}
domain/repository
ここには、外部リソースにアクセスするためのレポジトリのインターフェースを定義します。
これにより、使用者はどこのAPIからデータを取得しているのか、どこのデータベースから取得しているのかを意識することなくデータを取得することができます。
abstract class UserRepository {
/// ユーザー一覧の取得
Future<List<User>> findAll();
}
domain/usecase
ここには、アプリケーションの機能に関するユースケースのインターフェースを定義します。
ユーザー一覧を取得する
といったアプリケーションで実現化する機能ごとに作成します。
/// ユーザー一覧を取得する
abstract class GetUsersUsecase {
Future<List<User>> execute();
}
domain/factory
ここは、エンティティを生成するためのインターフェースを定義します。
他のサイトで紹介されているクリーンアーキテクチャを基にしたディレクトリ構成では、datasource
のmodel
にentity
を継承したり、toEntity()
メソッドを実装して変換を行っていますが、実際にエンティティを構築する際は、複数のdatasource
を使用しないと構築できない場合があるため、factory
というものを作成しました。
個人的に割と神分離できたかなと勝手に満足しています。
abstract class UserFactory {
/// [User]を生成する
User create({
required String name,
required String gender,
required String thumbnail,
required DateTime birthday,
});
/// [RugUser]から[User]を生成する
User createFromModel(RugUser user);
}
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
を注入し、それを使用するようにします。
注意として、クリーンアーキテクチャの依存関係から、datasource
やfactory
をここでは使用してはいけません。
今回は、単純にレポジトリの戻り値をそのまま返しているだけですが、アプリケーションロジックを書く場所であるため、実際にはもっと長くなるケースが多いです。
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で例を示しています。
/// https://randomuser.me
abstract class RandomUserGeneratorApiDatasource {
Future<RugGetUsersResponse> getUsers({int results});
}
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 の各先頭の文字を組み合わせました。
接頭辞がなくても、問題ない方は別につけなくても大丈夫です。
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(),
);
}
}
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
で定義したインターフェースの実装を行います。
datasource
やfactory
はconstructor()
から注入して使用するようにします。
datasource
から受け取ったmodel
からentity
に変換する際はfactory
を使用します。
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
など、初回生成時に決定するような値はここで生成するようにします。
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,
);
}
}
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
ここには、画面を構成する際の部品ウィジェットを定義します。
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
ここには、各画面を定義します。
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
を継承したクラスを定義します。
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
型に変換して生成します。
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
ここには、画面遷移用のNavigator
やshowAlertDialog
をラップしたものを定義します。
ここでラップすることにより、viewへのコード削減や、遷移時のアニメーションの統一をすることができます。
class PageNavigation {
static Future push(BuildContext context, Widget page) async {
return Navigator.of(context).push(
MaterialPageRoute(
builder: (context) {
return page;
},
),
);
}
}
さいごに
ここで紹介しているサンプルコードには、Providerを定義している部分は載せていませんが、個人的にはinterface
やnotifier
を定義しているファイルに書いています。
他の記述箇所の候補としては、core
ディレクトリ内に、provider
ディレクトリを作成し、その中で定義していくのもいいのかなと思ってます。
Discussion