この記事で出てきたfreature-firstなディレクトリ構成がよさそうだなと思い、
原文をちゃんと読んでみたときの備忘録(*´ω`*)
原文はこちら
原文で書かれていること、ざっくり
フォルダ構成に、Layer-firstとFeature-firstの2パターンがあるけど、
Feature-firstでつくるとよかったよ
というはなし。
ただ、前提として、この記事の筆者(Andreaさん)が考案したアーキテクチャである、
Riverpod App Architectureを採用している
さらに、Presentation Layerの章では、
Andoridのアーキテクチャガイドが参照されている
ちなみに、Flutter公式のApp Architectureはこんな感じ
共通するコンセプト
いずれもコアとしてあるのは、このあたり
- 関心の分離(Separation of concerns)
- 信頼できる唯一の情報源(Single source of truth / SSOT)
- 単方向データフロー(Unidirectional data flow / UDF)
- UIはデータモデルで操作する(UI = f(state))
主に、AndroidとFlutterの公式から抜粋
関心の分離
関心の分離(Wikipedia)は、最も重要な原則としてあげられている。
プログラムを関心・責務・役割ごとに分離し、クラスをできる限りシンプルに保つことで、
再利用やテスト、可読性を高くする。
いずれも、階層化アーキテクチャ(Layered architecture)を利用する形になっている
- UI / Presentation ... アプリの画面など
- Data ... データをやり取りするDBやAPIなど
- Domain / Logic ... (任意) UIとDataの間。ビジネスロジックなど
各レイヤの構成要素やDomain層の分割方法とかは、若干際があるが、概ねこの形
信頼できる唯一の情報源
データ型ごとに、そのデータ型の取得や変更を一つの場所(SSOT)にまとめる原則
SSOTのみがデータを変更でき、変更不可(イミュータブル)の型を使って公開する
これにより、以下のメリットがある
- 特定のデータ型に対するすべての変更を1か所に集約できる
- 他の型によって改ざんされないようにデータを保護できる
- データに対する変更が追跡しやすくなり、それによりバグを見つけやすくなる
一般的に、DataレイヤーのRepositoryと呼ばれるクラスに保持され、
データ型ごとに1つのリポジトリクラスが用意される
通常、DBやAPIなどのDataレイヤーがSSOTになるが、
場合によっては、ViewModelやUIがSSOTになることもある
単方向データフロー
「状態(State)」とその状態を表示する「UI」を切り離すのに役立つ設計パターン
状態は一方向にのみ流れ、データを変更するイベントはその反対方向に流れる
UIからのイベントから開始し、SSOTであるDataLayerからState(DBのデータ)を返す流れ
単一方向なので、データの流れがシンプルになり、間違えにくく、デバッグしやすくなる
また、リポジトリがAPIをポーリングして新しいデータを取得したときなど、
Dataレイヤーから開始することもある。
UIはデータモデルで操作する
Flutterは宣言的UIであり、現在の状態をそのままUIとして表示するように構築する
状態が変化に応じて、UIの再構築するため、「UI は状態の関数である」と表現される
あくまで、データがUIを操作するのであり、その逆ではない
Riverpod App Architecture
それを踏まえて、この記事を見ていく
Riverpod自体が、設計や構造化に関する話がないため、
試行錯誤の結果、考えたアーキテクチャらしい
あるあるな悩みがリストアップされている。どれも悩ましい。。。
- UIと、ビジネスロジックたデータアクセスロジックの分離
- 用意するクラスとその責務や、クラス間の連携・連動
- 複数人でそれぞれ機能開発する場合、うまくやるためには
- フォルダ構成やファイルの整理方法
- エラーハンドリングはどうするか? どこで処理され、UIにどのように伝播されるか?
- どのProviderを使うべきか? どこで宣言するすべきか?
Presentation Layer
- レイヤーの責務: 画面の表示、UIイベントの受け取り
- Widgets: ページなどのUIコンポーネント
- States: 画面の状態
- Controllers: 非同期のデータ変更を実行し、Stateを管理
- MVVMのViewModelやBLocのcubitと同じ役割
- AsyncNotifierProviderなどで実装されることが多い
Presentation Layerの解説はこのあたり。コードなども載ってる
ログイン画面の例が載っている
Controllerはこんな感じ
@riverpod class SignInScreenController extends _$SignInScreenController { @override FutureOr<void> build() { // no-op } Future<void> signInAnonymously() async { final authRepository = ref.read(authRepositoryProvider); state = const AsyncLoading(); state = await AsyncValue.guard(() => authRepository.signInAnonymously()); } }
画面はこんな感じで、AsyncValue
で状態を受け取り、
onPressed
に、Controllerのmethod(signInAnonymously
)を割り当てる形
class SignInScreen extends ConsumerWidget { const SignInScreen({Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { // watch and rebuild when the state changes final AsyncValue<void> state = ref.watch(signInScreenControllerProvider); return Scaffold( appBar: AppBar( title: const Text('Sign In'), ), body: Center( child: ElevatedButton( // conditionally show a CircularProgressIndicator if the state is "loading" child: state.isLoading ? const CircularProgressIndicator() : const Text('Sign in anonymously'), // disable the button if the state is loading onPressed: state.isLoading ? null // otherwise, get the notifier and sign in : () => ref .read(signInScreenControllerProvider.notifier) .signInAnonymously(), ), ), ); } }
Domain Layer
- レイヤーの責務: アプリ固有のモデルクラスの定義
- Models: 単純なデータクラス
Domain Layerの解説はこのあたり。コードなども載ってる
Modelだけと書いているが、モデルの変更を楽にする、
ヘルパー関数をextension
で提供する話も載っている
class Cart { const Cart([this.items = const {}]); /// All the items in the shopping cart, where: /// - key: product ID /// - value: quantity final Map<ProductID, int> items; factory Cart.fromMap(Map<String, dynamic> map) { ... } Map<String, dynamic> toMap() { ... } } /// Helper extension used to update the items in the shopping cart. extension MutableCart on Cart { Cart addItem({required ProductID productId, required int quantity}) { final copy = Map<ProductID, int>.from(items); // * update item quantity. Read this for more details: // * https://codewithandrea.com/tips/dart-map-update-method/ copy[productId] = quantity + (copy[productId] ?? 0); return Cart(copy); } Cart removeItemById(ProductID productId) { final copy = Map<ProductID, int>.from(items); copy.remove(productId); return Cart(copy); } }
Data Layer
- レイヤーの責務: DBやAPIなどさまざまな場所へのデータアクセス
- DataSources: アプリ外との連動に使われるサードパーティ部分
- DTOs: DataSourceから返されるデータクラス
- Repositories: SSOTの入口。Repository Pattern
Repository Patternの解説はこのあたり。コードなども載ってる
Repositoryの抽象クラスが必要かどうかの見解が書かれていて、この記事も面白い
(具象クラスが1つだけなら、抽象クラスは不要とのこと)
Application Layer
- レイヤーの責務: Presentation LayerとData Layerの橋渡し
- 必要なときのみ。複数の構成要素と依存する場合に使う
- Services: アプリ固有のロジックの実装
- 複数のControllerと複数のRepositoryの仲介
Application Layerの解説はこのあたり。コードなども載ってる
Servicesでは、以下は"行わない"
あくまで、アプリ固有のロジックの実装のみ
Feature-firstでのディレクトリ構成
ここまできて、やっと本題
最終的なプロジェクト構造はこんな感じらしい。
features
配下がFeature-firstなディレクトリ構成の部分
‣ lib ‣ src ‣ common_widgets ... 共通Widget ‣ constants ... 定数 ‣ exceptions ... 例外 ‣ features ‣ authentication ... 認証機能 ‣ presentation ‣ application ‣ domain ‣ data ‣ products ... 商品機能 ‣ presentation ‣ application ‣ domain ‣ data ‣ localization ... 多言語対応 ‣ routing ... ルーティング ‣ utils ... utils
Feature-firstのメリット
書かれているメリットは、以下の2つ
- 機能ごとにまとまっているので、1つのフォルダに集中できる
- 機能を削除するときは、1つのフォルダだけでOK
ただ、
しかし、現実の世界では物事はそれほど簡単ではありません。
However, things are not so easy in the real world.
らしい。。。
機能(Feature)とはなにか
機能は、UIなどユーザが見るものではなく、
ユーザが何をするかとのこと。
たとえば、ECサイトの場合だと、
- 認証する
- ショッピングカートを管理する
- チェックアウト
- 過去の注文をすべて表示
- レビューを残す
など。
Feature-firstをうまくやるには
ドメイン駆動設計(DDD)のように、
ドメイン層を中心に編成するとうまくいくらしい
- ドメイン層からはじめ、モデルとそれらを操作するビジネスロジックを特定
- 一緒に属する各モデル(またはモデルグループ)のフォルダを作成
- そのフォルダにレイヤーごとのサブフォルダを作成(
presentation
、application
、domain
、data
)
良くないフォルダ構成の例
ドメインではなく、画面単位でまとめた場合、
うまくいかなかったらしい
‣ lib ‣ src ‣ features ‣ account ‣ admin ‣ checkout ‣ leave_review_page ‣ orders_list ‣ product_page <- 同じproductドメイン ‣ products_list <- 同じproductドメイン ‣ shopping_cart ‣ sign_in
そうではなく、機能の中心となるモデルを起点にすると、
うまくいく、フォルダ構成ができたとのこと
‣ lib ‣ src ‣ features ‣ products ‣ application ‣ data ‣ domain ‣ presentation ‣ admin ‣ product_screen ‣ products_list
まとめ
Reverpod App Architectureベースの
Feature-firstなディレクトリ構成について、だいぶ理解できた気がする
- Flutter Project Structure: Feature-first or Layer-first?
- Flutter App Architecture with Riverpod: An Introduction
実例やコードでの説明もあるので、試しつつ、また参照していくのがよさそう
とはいえ、実際にやるとハマるところが多そうなので、いろいろ試しつつ。。。(*´ω`*)