はじめに
2024年11月21日,22日に開催されたFlutterのカンファレンス「Flutter Kaigi 2024」に参加してきました。
パッケージ分割で機能やレイヤーを分ける「実践的パッケージ戦略」のセッションの内容に共感でき、勉強になったので実際にベースアプリを作成をしてみました。
学んだことや作ったアプリのアーキテクチャについて説明していきたいと思います。
※ 層の名称などセッション内で紹介されたアプリ構成とは一部異なります。
クリーンアーキテクチャについて
以下のような図を見たことがある人は多いと思います。
この図はクリーンアーキテクチャを表したものです。
業務知識(Entities)の処理がシステムの中心になっており、外部サービスやDB、UIなどが業務知識に依存するような依存方向性・構成になっています。
これにより、業務知識の処理が外部に依存しなくなるので、外部変更に強いアプリを作ることができます。
また、アプリの横展開するときも外部サービスやDB、UIを変えるだけで良いので流用性の観点から見ても良いです。(Entitiyやusecaseを流用できる)
画像: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
クリーンアーキテクチャ構成のアプリを以下のように開発してくことがあります。※一例
- ドメインオブジェクト実装
- Entity, Value Object, Domain Service, repository(インターフェース)などで業務知識を表現
- Usecaseの実装
- ドメインオブジェクトを使用してユーザー操作のユースケースを実装
- ViewModel(≒Controller)の実装
- ユーザーの入力等を使用して、Usecaseを実行する処理を実装
- UIでViewModelを呼び出す処理実装
上記に加えて、依存性逆転の原則(DIP)を実現するために、
Entities層のrepository(インターフェース)にGateways層のrepositoryをDI(依存性注入)します。
これにより、図のようにUsecase層はGateways層でなくEntities層に依存するように書くことができます。
開発時の問題点
ただ開発が進むと、Entity層やUsecase層でDevices層のクラスが使われたり、Controllers層でUI層のロジックが使われるようになってくることがあります。
依存方向性に反したコードが出てくると、Devices層やGateways層の変更が、ドメイン層にも影響するようになります。
依存方向性に反しないコードを書けばいいのですが、複数人で開発したり、
アプリのサービス期間が長くなったりする場合、依存方向性を守ることは難しくなる場合があります。
この問題に対して、各層をパッケージ化することで実装時に依存方向性を強制することができます。
Melos
Melosは、DartおよびFlutterプロジェクトのモノレポ管理ツールです。
各層(Entities層, Usecase層など)をパッケージ化することによって、
1つのGitレポジトリで複数のパッケージを管理するモノレポ(Monorepo)構成になります。
複数のパッケージができるのでflutter pub get
やflutter pub run build_runner build --delete-conflicting-outputs
などのコマンドをパッケージごとに実行する必要があります。
ただ、1つ1つ上記のコマンドなどを実行するのは、手間がかかるのでモノレポ管理ツールである「Melos」を使用します。
Melosを使用することで、「複数のパッケージで同時にコマンドを実行する」ことができます。
以下のコマンドでMelosをインストールします。
dart pub global activate melos
Flutterプロジェクトを新しく作成した場合は、以下のコマンドでMelosをプロジェクトに追加します。
dart pub add melos --dev
プロジェクトルート配下にmelos.yaml
を作成して、一括で実行したいスクリプトを書くことができます。
そのあと、以下のコマンドでスクリプトを実行することができます。
melos run 〇〇(定義したコマンド名)
クリーンアーキテクチャ+MVVMのサンプルアプリ
MVVMを採用したモバイルアプリケーションを想定しています。
また、クリーンアーキテクチャに加えてDDD(ドメイン駆動設計)も取り入れています。
クリーンアーキテクチャの図では「Entities」になっていますが、DDDで使われるEntityと似ているため「Entities層 = domain層」にしています。
「Controller」,「Presenter」の役割はMVVMではViewModelが担うので、「viewModel層」にしています。
「Devices」や「DB」などは一つにして「infrastructure」にしています。
(クリーンアーキテクチャの図はMVVMよりサーバサイドのMVCとかがあってそう)
依存性逆転の原則(DIP)や依存性注入(DI), データ転送オブジェクト(DTO)を使うことで依存を一方向にしています。
「app」パッケージ
Flutterアプリのメインとなるパッケージ。
エントリーポイントのmain.dart
やアプリ全体の処理を書くapp.dart
などのみを配置します。
ここでDIを行います。(ProviderScopeのoverridesを使用する)
「domain」パッケージ
業務知識を表現したクラスがあるパッケージ。
ドメインオブジェクト(user
, product
)や値オブジェクト(name
, Id
..)などの業務知識を抽象化したクラスを配置します。
データ取得を外部に依存しないようにするために、repositoryのインターフェースを配置しています。(DIでGatewaysの実装を注入)
domainパッケージは、他のパッケージに依存せず完結しています。
本当はdart言語のみで書いて完全に外部に依存しないように書くのが理想です。
※ データクラスを作成するためにfreezed
のパッケージを使用
※ riverpodでrepositoryのインターフェースを提供(これはdomain外にかける)
「gateway」パッケージ
domainパッケージのrepository(IF)を実装したクラスがあるパッケージ。
domainにある抽象化されたrepositoryを実装したクラスがあり、それらはDIして使用されます。
gatewayパッケージは、domainパッケージのみに依存します。
※ 外界と通信するという意味でgatewayという命名を採用しています。(Repository層だと「repository = DBのCRUD」のイメージが強い)
「infrastructure」パッケージ
DBや外部サービスと通信するクラスがあるパッケージ。
infrastructureパッケージは、gatewayパッケージのみに依存します。
またgateway以外からは参照されません。
「usecase」パッケージ
usecaseパッケージは業務知識(domain)を使用してシステムの一連の処理を行うクラスがあるパッケージ。
usecaseパッケージは、domainパッケージのみに依存します。
クリーンアーキテクチャ図の右下にあるものは以下のように省略しています。
- 「Use Case Output Port」=> 戻り値で返す
- 「Use Case Input Port」=> 引数でDTOを渡す
「view_model」パッケージ
UIの表示データ、UIからusecase処理を実行するメソッド、を持つクラスがあるパッケージ。
view_modelパッケージは、usecaseパッケージのみに依存します。
情報ごとにデータを保持するStateを作成します。(userState
, cartState
など)
ページごとにデータを保持するStateを作成します。(myHomePageState
など)
ページのStateを更新して画面を更新することもできますが、
どの画面からも共通で参照される情報ごとのデータを更新するようにします。
こうするとViewModelにおける「信頼できる唯一の情報源(single source of truth)」を実現できます。
「presentation」パッケージ
UIのウィジェットクラスがあるパッケージ。
presentationパッケージは、view_modelパッケージのみに依存します。
presentationパッケージは他のパッケージからは依存されません。
Flutterのウィジェットに関係あるパッケージ・クラスなどは、ここでしか使用されません。
(flutter/material.dart
, BuildContext
など)
依存関係
各パッケージ内のpubspec.yaml
の依存関係に必要なパッケージの依存関係を追加することで使えるようになります。
name: usecase
dependencies:
domain:
path: ../domain
上記のように依存関係を追加して、melosでDOT言語を出力すると以下のような依存関係になります。(※ appは図から削除しています)
まとめ
レイヤーをパッケージ化して依存関係を厳密にするのは、とてもいいなと感じました。
開発時に依存方向性を強制することができるので、どんな人が書いても適切な依存関係のある層のクラスを使うようになります。
ドメイン層に変な依存が入ることがないので、他プロジェクトへの流用性も高くなります。
ただ実装コストは高いので、そこまでやるかはプロジェクト次第だと思いました。