Flutterで私が考える最高のアーキテクチャ設計を詰め込んでみた
はじめに
また1年後に見返すと恥ずかしくなるような記事を書いてみようと思います。いつもこれが最高の設計だ!って書いている時は思うのですが、過去に自分が書いたコードって「やらかしてるなー。はずかしー。」って思いますよね。今回はそんな回です。いままでバックエンドやフロントエンド、インフラ構築やクラウドなど、いろいろ経験してきたのですが、スマホアプリの開発をしたことがなく、今年になって初めてのスマホアプリ開発にチャレンジしています。既存アプリの保守ではなく、ゼロから作るところから一人でやらせてもらえるというチャンスに巡り合いまして、「私が考える最高のアーキテクチャ設計はこれだ!」を詰め込んでみようと思いました。
詰め込んだ要素
1. DDD(Domain-Driven Design: ドメイン駆動設計)
私の開発チームではドメインモデリングを積極的に取り入れているため、基本はDDDのディレクトリ構成にしようと考えました。
2. BLoCパターン(Business Logic Component)
Flutter経験のあるエンジニアの方にBLoCパターンいいよとおすすめしてもらったので取り入れてみました。FlutterアプリケーションでビジネスロジックとUIを分離するためのアーキテクチャパターンです。
3. 依存性の注入(Dependency Injection, DI)
主にテスト容易性のために、入れました。
こだわり構造
lib
├── domain
├── infrastructure
├── application
└── presentation
Domain(ドメイン層)
ビジネスロジックやルールを表現する中心的な層であり、システムが扱う対象領域(ドメイン)そのものを定義します。アプリケーションの「心臓部」とも言え、複雑なビジネスルールやロジックはすべてここで表現されます。
lib/domain
├── entities
├── value_objects
└── repository
Entity(エンティティ)
ドメインモデルにおけるエンティティを定義。エンティティの要素にはバリューオブジェクトである Id
や Email
が使われています。
class User with _$User {
const factory User({
() required Id userId,
required String userName,
() required Email email,
[...省略...]
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
ValueObject(バリューオブジェクト)
ドメインモデルにおけるバリューオブジェクトを定義。バリデーションルールなどはここに持たせるといいなぁと思いました。
class Email extends Equatable {
final String value;
const Email._(this.value);
// Emailアドレスのバリデーション
static void validate(String input) {
[...省略...]
}
// ファクトリコンストラクタなど
}
Repository(リポジトリ)
ドメイン層とインフラ層の間の橋渡しをする役割を持ちます。例はapiとしていますが、他にstorageやauthなどのように、インフラごとにディレクトリを切ります。
abstract class UserRepository {
Future<User> getUser(Id userId);
Future<void> updateUser(
Id userId, String userName, Email email);
}
Infrastructure(インフラ層)
ドメインのリポジトリに対する実装部分を、技術スタックごとに分けて実装。
lib/infrastructure
└── api
└── dio
└── repositories
実装
HTTPクライアントをdioにしているため、dioクライアントをDIしています。dio独特の実装は、リポジトリのピュアロジックには関係がないため、実装部分にしか登場しません。そのためdioでディレクトリを切っていて、dio以外のHTTPクライアントを使いたくなった際は、新しくディレクトリを切ってすぐに切り替えられるように変更容易性を高めています。
class UserRepositoryDio implements UserRepository {
final DioClient dioClient;
UserRepositoryDio(this.dioClient);
Future<User> getUser(Id userId) async {
try {
final response = await dioClient.dio.get('/users/$userId');
return User.fromJson(response.data);
} catch (e) {
throw Exception('Failed to fetch user');
}
}
Application(アプリケーション層)
ビジネスロジックの調整役として機能する層
lib/application
├── usecases
└── services
ユースケース
特定の操作やビジネス要件を実現するためのユースケースを提供します。リポジトリをDIします。
class UserUsecase {
final UserRepository repository;
UserUsecase(this.repository);
Future<User> getUser(Id userId) async {
return await repository.getUser(userId);
}
[...省略...]
}
サービス
複数のリポジトリとやりとりする調整役
Presentation(プレゼンテーション層)
ユーザーが直接触れるUI(View)を提供。BLoCやプロバイダーもここで管理。
lib/presentation
├── bloc
├── providers
│ ├── bloc_provider.dart
│ ├── usecase_provider.dart
│ ├── service_provider.dart
│ └── repository_provider.dart
└── view
└── pages
BLoC(Business Logic Component)
イベントやステートも同じファイルで管理します。ユースケースをDIします。
class UserBloc extends Bloc<UserEvent, UserState> {
final UserUsecase userUsecase;
UserBloc(this.userUsecase) : super(UserInitial()) {
on<GetUserEvent>((event, emit) async {
emit(UserLoading());
try {
final user = await userUsecase.getUser(event.userId);
emit(UserLoaded(user));
} catch (e) {
emit(UserError("Failed to fetch user data"));
}
});
[...以下略...]
Provider(プロバイダー)
Riverpodを使って、dioClient > dio実装リポジトリ (> サービス) > ユースケース > BLoCの順に依存性を注入します。ウィジェットはプロバイダーが提供するBLoCを使います。
final userBlocProvider = Provider((ref) {
final usecase = ref.read(userUsecaseProvider);
return UserBloc(usecase);
});
// その他のBLoC
final userUsecaseProvider = Provider((ref) {
final repository = ref.read(userRepositoryProvider);
return UserUsecase(repository);
});
// その他のユースケース
ウィジェット
BLoCをウィジェットで使いますがflutter_blocが提供しているBlocProviderは使いませんでした。
DIされたBLoCを使いたかったため、Riverpodが提供するblocを使うようにしました。
class UserPage extends HookConsumerWidget {
const UserPage({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final userBloc = ref.watch(userBlocProvider);
return Scaffold(
appBar: AppBar(),
body: Container(
color: BackgroundColors.light,
child: BlocBuilder<UserBloc, UserState>(
bloc: userBloc,
builder: (context, state) {
if (state is UserLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is UserError) {
return Center(child: Text(state.message));
} else if (state is UserLoaded) {
return Center(child: Text(state.userName));
} else {
return SizedBox.shrink();
}
[...以下略...]
おわりに
以上が大枠の作りです。色々省略しながら書いてしまいましたが、伝わったでしょうか。他にもResult型を入れたかったなど、やり残したことはありますが、今回はここまでになります。バリューオブジェクトにバリデーションを定義してみたのはよかったなぁと個人的に思っています。入力フォームバリデーションを実施する時も、それを呼び出すだけにしているので、インスタンス作成時とロジックが統一されてよいです。エンティティについては、freezedが若干使いづらいと感じました、ビジネスロジックを書きたいシーンが何回かあったのですが、freezedの制約でできず、拡張メソッドで定義しないといけないなどがありました。
また、来年あたり「私が考える最高のアーキテクチャ設計はこれだ!」って全然別のコードを書いているんだろうなぁと思うと、すでに恥ずかしいです。
来年もよろしくおねがいします。
Discussion