📱

Flutterで私が考える最高のアーキテクチャ設計を詰め込んでみた

2024/12/12に公開

はじめに

また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(エンティティ)

ドメインモデルにおけるエンティティを定義。エンティティの要素にはバリューオブジェクトである IdEmail が使われています。

lib/domain/entities/user.dart

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(バリューオブジェクト)

ドメインモデルにおけるバリューオブジェクトを定義。バリデーションルールなどはここに持たせるといいなぁと思いました。

lib/domain/value_objects/email.dart
class Email extends Equatable {
  final String value;

  const Email._(this.value);

  // Emailアドレスのバリデーション
  static void validate(String input) {
    [...省略...]
  }

  // ファクトリコンストラクタなど
}

Repository(リポジトリ)

ドメイン層とインフラ層の間の橋渡しをする役割を持ちます。例はapiとしていますが、他にstorageやauthなどのように、インフラごとにディレクトリを切ります。

lib/domain/repository/api/user_repository.dart
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クライアントを使いたくなった際は、新しくディレクトリを切ってすぐに切り替えられるように変更容易性を高めています。

lib/infrastructure/api/dio/repositories/user_repository.dio.dart
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します。

lib/application/usecases/user_usecase.dart
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します。

lib/presentation/bloc/user_bloc.dart
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を使います。

lib/presentation/providers/bloc_provider.dart
final userBlocProvider = Provider((ref) {
  final usecase = ref.read(userUsecaseProvider);
  return UserBloc(usecase);
});
// その他のBLoC
lib/presentation/providers/usecase_provider.dart
final userUsecaseProvider = Provider((ref) {
  final repository = ref.read(userRepositoryProvider);
  return UserUsecase(repository);
});
// その他のユースケース

ウィジェット

BLoCをウィジェットで使いますがflutter_blocが提供しているBlocProviderは使いませんでした。
DIされたBLoCを使いたかったため、Riverpodが提供するblocを使うようにしました。

dart
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