🖼

【Flutter】アプリ全体のアーキテクチャを0から考えて作り直した話

2022/09/29に公開約13,900字11件のコメント

ここ半年ほど、仕事で Flutter アプリを 0 から作り直しています。

ちょうど今年の個人的なテーマを「アーキテクチャ」に据えていたこともあり[1]、またその一環として 「Clean Architecture 達人に学ぶソフトウェアの構造と設計」 (以下:クリーンアーキテクチャ本)を読んでいたこともあり、この作り直しでは「アーキテクチャ」をしっかりと自分の頭で考えながら作ろうと決めて取り組んできました。

アーキテクチャについて頭を悩ませながら実装を進めること約半年、ようやくアプリが形になるとともにある程度知見も溜まってきましたので、その知見を一般化した内容をこの記事にまとめていきたいと思います。

注意

この記事は、「Flutter アプリのアーキテクチャはこれがベストプラクティス!」という類の記事ではありません。あくまで 私の目の前の要件ではこれが最適と判断した という一例の紹介になります。

ここに書く内容はどのようなアプリでもそのまま適用してうまくいくようなものではありません。クリーンアーキテクチャ本でも強調されている(と私は理解している)ように、 それぞれのソフトウェアの開発者がそれぞれの要件や状況を考慮した上でその時点での最適なアーキテクチャを考え続ける のが大事だと思っています。

「次のアプリはここに書かれたパターンに当てはめて作ってみよう」と考えるのではなく、「ってことは自分のアプリだとこうするのが良さそうかな?」と考えながらこの記事を読んでいただければと思います。この記事で紹介するアーキテクチャも、一般的に紹介されている何か具体的なパターンに必要以上に引っ張られないように意識して考えています。[2]

アプリの主な要件

アーキテクチャの具体的な話に入る前に、私が開発中のアプリがどのような要件なのかを説明します。具体的なアプリ名や内容までは書けないので、アーキテクチャの検討に強く影響している要素を列挙します。

バックエンド(今回は Firestore)に保存したデータを取得して画面に表示するパターンが多め

いわゆる「JSON に色を付ける」程度で事足りる機能が 6 割程度を占めます。ただし、いくつかの機能では複数のコレクションからデータを引っ張ってマージしたりする必要があったり、取得したデータをアプリ内のロジックに従って一度料理してから UI に表示するような機能もあります。逆に保存すべきデータをアプリ内のちょっとした処理で生成しなければならないような機能もあります。

つまり、データをそのまま入出力する「だけ」とは言えないものの、一方で込み入ったシステムがアプリ内に必要なわけでもない、という温度感です。言い換えると、Widget クラスと Firestore の操作クラスを直接繋ぐとその間のロジックが大変なことになります。

「似たような機能を持った別アプリ」の開発・リリースを念頭におく

今回開発したアプリをベースに、ターゲットやデザインコンセプトを変更した別バージョンのアプリや、一部の機能のみを抽出して特化させたアプリの開発をビジネス的な方針として検討されている、というのも大事な要件のひとつです。そのため、コードは可能な限り共通利用できる形で設計することが求められます。

GPS を利用するため机上で開発・デバッグしづらい

このアプリは GPS を利用した機能を中心としています。そのため、机上の開発には GPS をエミュレートする機能が必要になるのですが、Android / iOS 各プラットフォームに標準で用意されているエミュレート機能を利用するにはそれぞれに一手間が必要だったり制約があったりします。[3]

そのため、任意のロジックに従ってダミーの位置情報する仕組みと、それをビルド時の設定等で簡単に切り替えられる仕組みが必要です。


というようなアプリであることを念頭に読み進めていただければと思います。

全体を 3 つのレイヤー +α に分け、「境界」と「依存関係」を整理する

まずは大枠から考えます。クリーンアーキテクチャ本を読んでまず自分が学んだのは、 レイヤーの「境界」を定義し、レイヤーの「依存関係」を整理する ことです。

以下の図が今回のアプリの大枠となるアーキテクチャです。境界がどこにあるのか、それぞれのレイヤーの依存関係がどうなっているのかに着目して見ていただければと思います。

アーキテクチャ概要

図にするととてもシンプルですね。どこかで見たような分け方と感じるのではないかと思います。重要なのは、それぞれのレイヤーに設定するルールと、依存関係に対するルールを適切に決めて守ることです。

ルールを定める

それでは、先述したルールがどのようなものなのかを確認していきましょう。その後、そのルールによって生まれるメリットを整理していきます。

依存関係のルール

まずは依存関係についてです。「依存関係」については、言葉で長々と説明するよりも具体的なコード例を使って説明を進めます。

たとえば、「グループ内のメンバー一覧を取得して画面に表示する」ような機能をイメージするとき、以下のコードは View レイヤーに含まれる MembersState クラスが BusinessLogic レイヤーに含まれる MembersLogic クラスを呼び出している(参照している)ため、「MembersStateMembersLogic に依存している」と説明できます。

view/member_state.dart
import 'package:myapp/businesslogic/member_logic.dart';

class MemberState extends State<MemberScreen> {
  final logic = MemberLogic();

  // 画面表示に利用するメンバー一覧
  late List<Member> _members;

  
  void initState() {
    // MemberLogic を通してデータを取得
    _members = logic.getMembers();
  }
}

最初の図を見ると、View レイヤーと BusinessLogic レイヤーの依存関係は View -> BusinessLogic と決めているため、上記のコードは OK です。

一方で、View レイヤーと Repository レイヤーの間には矢印がないため、以下のコードは NG となります。

view/member_state.dart
import 'package:myapp/repository/firestore_member_repository.dart';

class MemberState extends State<MemberScreen> {
  final repository = FirestoreMemberRepository();

  // 画面表示に利用するメンバー一覧
  late List<Member> _members;

  
  void initState() {
    // MemberLogic を通してデータを取得
    _members = repository.fetchMembers();
  }
}

このように「どのレイヤーはどのレイヤーに依存してよいか(参照してよいか)」をアーキテクチャのルールとして定めています。

ちなみにコードがこのルールに従っているかどうかは、import 文を見れば一発でわかります。

View -> BusinessLogic の場合、import 文に並ぶファイルは必ず businesslogic/xxxx.dart となるはずです。一方で repository/xxx.dart が import 文に含まれる場合、それは View -> Repository の依存関係ができてしまっていることになるため、機械的に NG と判断できます。

これを静的に解析するために、今回は import_lint というパッケージを導入してみています。

https://pub.dev/packages/import_lint

依存関係の逆転

さて、 View -> BusinessLogic のように、呼び出しの経路がそのまま依存の方向になっている場合は話がシンプルなのですが、BusinessLogic と Repository の関係をみてみると、BusinessLogic が Repository を呼び出したい にもかかわらず、依存関係は Repository -> BusinessLogic となっています。

このように「呼び出し」の関係と「依存」の関係を逆転させてルールづけている理由については後述しますが、ここでは「依存関係の逆転」という考え方を使って依存の方向性を整理する手法について先に説明します。

まず、先ほどのコード例をみるとわかる通り、「BusinessLogic が Repository を呼び出す」をそのまま実装しようとすると依存関係は BusinessLogic -> Repository となってしまいます。

これを「逆転」させるには、インターフェースを BusinessLogic レイヤーの中に定義し、MemberLogic クラスはそのインターフェースにのみ依存するようコーディングします。

businesslogic/interface/member_repository.dart
// メンバーデータにアクセスするためのインターフェース
abstract class MemberRepository {
  Future<List<Member>> fetchMembers;
}
businesslogic/member_logic.dart
import 'package:myapp/businesslogic/interface/member_repository.dart';

class MemberState extends State<MemberScreen> {
  MemberState(this.repository);

  final MemberRepository repository;

  Future<List<Member>> getMembers() async {
    return await repository.fetchMembers();
  }
}

次に、Repository レイヤーには MemberRepository を継承した FirestoreMemberRepository クラスを実装します。

repository/firestore_member_repository.dart
import 'package:myapp/businesslogic/interface/member_repository.dart';

// Firestore にアクセスしてメンバーデータを出し入れするクラス
class FirestoreMemberRepository extends MemberRepository {
  
  Future<List<Member>> fetchMembers() async {
    // Firestore の所定のコレクションからメンバーデータを取得してくる処理。
  };
}

このようにすることで、import 文に着目すると確かに BusinessLogic レイヤーの MemberLogic クラスは同じレイヤー内のファイルにのみ依存していて、一方で Repository レイヤーの FirestoreMemberRepository クラスは BusinessLogic レイヤーのファイルに依存する( Repository -> BusinessLogic )という、呼び出しの方向と依存の方向の「逆転」が見てとれるでしょう。

「なんだ、インターフェースとは言え Repository の名前がついたファイルを無理矢理 businesslogic フォルダに置いただけじゃないか、ただの言葉遊びじゃないか。」と思われる方もいるかもしれませんが、[4]このテクニックを使って依存関係を整理することで明確なメリットが生まれます。次はそれについてみていきましょう。

依存関係を整理するメリット

今回のアーキテクチャにおいて、依存関係をキッチリ整理するメリットのひとつが「交換可能性」です。

その端的な例が BusinessLogic レイヤーで、図をみると BusinessLogic レイヤーは(Data を除いて)他のどのレイヤーに対しても矢印が向かっていません。つまり、他のレイヤーにどのような変更があったとしても、極端な話 View レイヤーと Repository レイヤーをフォルダごと削除したとしても BusinessLogic レイヤーにはコンパイルエラーは発生しない ということです。

これは以下の 2 つの要件を満たすのに役立ちます。

  1. 「似たような機能を持った別アプリ」を開発する
  2. データ取得元(特に GPS)をエミュレートする

それぞれ詳しくみていきましょう。

1. 「似たような機能を持った別アプリ」を開発する

たとえば 「UI をガラッと変えて機能も絞り込んだ別アプリを開発したい」 という話がビジネス的に持ち上がった場合、開発者は「じゃあこのクラスとコレとコレを共通化して、あっちは少し切り離して、、ああこのクラスも一緒にリファクタしなきゃなのか、、」といったアレコレを考える必要はありません。機械的に businesslogic フォルダをコピー & ペーストし[5]、その中の必要なクラスだけを呼び出す新しい View レイヤーを作ればよいだけ です。

またその際、データの保存先やデータ形式が同じ場合は Repository レイヤーも一緒にコピペできます。逆にもし「今回のアプリは Firebase じゃなくて AWS で API 用意するからそれ叩いてよ」となった場合は Repository レイヤーを実装し直すだけで BusinessLogic レイヤーはやっぱり何も変更せずに使いまわせます。

2. データ取得元(特に GPS)をエミュレートする

別アプリを作る場合以外でも、たとえば「実行モードによってデータの保存・取得先を変更する」ような要件にも対応しやすくなります。

たとえば 「Firestore にちゃんと接続して UI からデータベースまで通して動作確認するモード」と「Firestore の都合に左右されずに UI を開発したいからメモリ上だけでデータを出し入れする簡易モード」を切り替えたい 場合を考えてみましょう。コードとしては、 FirestoreMemberRepositoryInMemoryMemberRepository の 2 つクラスを Repository レイヤーの中に作ることになります。

そのどちらのクラスを利用するかを実行時の引数(たとえば --dart-define など)で切り替えることを考えたとき、アーキテクチャの図をみてもわかるとおり Repository レイヤーに向かう矢印はどこにも存在しないため、View や BusinessLogic など他のレイヤーを考慮する必要は一切ありません。「このモードの場合はロジックのここに影響がでちゃうからここも一緒に if で分岐して、、ああやっぱりリファクタ必要かも、、」と悩む必要はないのです。

同じ要領で GPS もエミュレート可能です。「端末の GPS から位置情報を取得するクラス」と「ロジックに従ってランダムな位置情報を返却するクラス」、もしくは「過去のデータを取得して移動を再現するクラス」を Repository レイヤーに用意したら実行時の引数に従って切り替えれば良いだけです。View や BusinessLogic はその緯度経度がどこから発生したものなのかを気にする必要はありません。[6]


このように、依存関係を整理することで、どこが切り離し可能で、その際どこに影響が出るのか(特に「コンパイルエラー」という形で)が予測しやすくなり、それによって別アプリの開発やちょっとしたエミュレート機能の作り方がイメージしやすくなるのがとても大きなメリットだと感じています。

レイヤーごとのルール

さて、レイヤーを分け依存関係を整理できたところで、次はそれぞれのレイヤーにどのようなコードを実装するのかについてのルールをみていきましょう。

個別の説明に入る前に、先ほどの図に Flutter や Firebase など自分たちが書くコード以外の関係性も追記してみましょう。

より詳しいアーキテクチャ

こちらを見るとわかる通り、Flutter に依存してよいのは View だけ、Firebase に依存してよいのは Repository だけ 、となっています。このことを念頭に読み進めていただければと思います。

BusinessLogic

まずは依存関係の中心となる BusinessLogic レイヤーです。

BusinessLogic には、その名の通り「ユーザーのやりたいこととその手順」をコードで表現します。[7]これだけだと具体的ではないので説明を加えると、「どんなプラットフォームで動作するシステムであっても、なんならシステムですらなくてもユーザーがやりたいこと」をやるのがこのレイヤーと考えています。

たとえば、「自分の現在地から半径300mの中にあるラーメン屋を近い順にリストアップしたい」という「やりたいこと」があった場合、その手順を言葉で表すと以下の通りです。

  1. 日本中のラーメン屋をすべてリストアップして
  2. 自分の現在位置を調べて
  3. その一件一件が現在位置から何メートル離れているかを計算して
  4. 計算結果が300m以内だったらそのラーメン屋をどこかにメモしておいて
  5. 最後まで調べ終わったらメモを見返して
  6. 近い順に並べ替えたメモを作ったら完成

となります。

この作業は(時間や能力的な制約を考えなければ)アプリでもPCでも、GUI でも CUI でも、コンピューターでも手作業でもできると言えるのではないでしょうか。そのような作業手順をコードで書き表すのが BusinessLogic レイヤーです。

言い換えると、このレイヤーのコードには「Flutter を使う」ことも「Firebase からデータを取得する」ことも書いてはいけません。極端な話、 一切のパッケージを利用せずに Dart の標準クラスのみで実装する ことを目指すレイヤーであると言っても過言ではありません。

例えば、上の例を実現する RamenListLogic クラスのコードは以下のようなイメージになるでしょう。(あくまでイメージです)

ramen_list_logic.dart
import 'package:myapp/businesslogic/interface/ramen_repository.dart';
import 'package:myapp/businesslogic/interface/location_repository.dart';
import 'package:myapp/data/restaurant.dart';

class RamenListLogic {
  RamenListLogic(this.ramenRepository, this.locationRepository);

  final RamenRepository ramenRepository;
  final LocationRepository locationRepository;

  Future<List<Restaurant>> getVisitableRestaurants() async {
    final allRestaurants = await ramenRepository.fetchAll();
    final currentLocation = await locationRepository.detectCurrentLocation();
    final visitableRestaurants = allRestaurants.where((restaurant) {
      return _calcDistance(restaurant.location, currentLocation) <= 300;
    }).toList();
    return visitableRestaurants..sort((r1, r2) => r1.distance - r2.distance);
  }
}

上記のコードのポイントは以下のとおりです。

  • viewrepository フォルダ内のファイルを import しない
  • Flutter や Firebase が提供するファイルをインポートしない
  • RamenRepositoryLocationRepository の具象クラスは外から受け取る(今回はコンストラクタで)

これによって「他のレイヤーに一切依存しない(Data はちょっと例外として)」「ピュアな Dart コードのみで実装された」クラスが完成します。

このとき、Repository のインターフェースには BusinessLogic を表現する上で最も素直に利用できる形のメソッド を定義しています。個人的な BusinessLogic レイヤーのイメージは 他の何も気にすることなく最もワガママに実装できるレイヤー です。何かアプリの機能を実装する、となった際はまずここからスタートするように意識しています。Firestore 上のデータ形式がこうだからとか、画面遷移がこうなっているからとか、そういったことは一切考えてはいけません。

こうすることで、単体テストや動作確認がとても楽になります。このレイヤーのコードは Flutter に依存しないため flutter run でアプリを動かすことなく dart コマンドでサクッと実行できますし、単体テストであれば dart test で動作確認が可能です。[8]

コンストラクタに渡すべき Repository クラスもテストしやすい適当なモックを作れば OK でしょう。

mock_ramen_repository.dart
import 'package:myapp/businesslogic/interface/ramen_repository.dart';

class MockRamenRepository extends RamenRepository {

  /// 適当に固定データを返却する
  
  Future<List<Restaurant>> fetchAll() async {
    return const [
      Restaurant(...),
      Restaurant(...),
      Restaurant(...),
      Restaurant(...),
      Restaurant(...),
      Restaurant(...),
    ]
  }
}

Repository

Repository レイヤーは データベースにおいて発生するすべての諸事情を吸収する のが役割です。

諸事情とは、たとえば以下のようなものがあります。

  • データベースには Firestore を利用する
  • でも部分的に Functions を叩いて取得する
  • 目的のデータを構築するために複数のコレクションからデータを引っ張ってくる必要がある
  • 機能追加に伴ってちょっとデータ構造的に破綻する箇所が出てきたから、データ構造や名前を変更する
  • こっちの派生アプリでは大人の事情で AWS を使う

ワガママな BusinessLogic が指定したインターフェースに従ってデータを返却できるよう、データの取得方法と変換を行うコードをここに書いていくと考えるとよいでしょう。

理想的な姿は、バックエンドにどのような事情が発生しようとも、View や BusinessLogic レイヤーのコードを一切変更することなく解決できることです。

そのため、たとえば Firestore パッケージが用意する DocumentSnapshot のような型のまま BusinessLogic レイヤーにデータを返却することはできません。かならず Data レイヤーで自分が定義した型に変換してから返却します。View や BusinessLogic のファイルに Firestore の import 文が見えた瞬間、それは何かが間違っていると判断します。

UI

UI はおそらく一番イメージしやすいのではないでしょうか。

Figma を見ながら Flutter の書き方に従って Widget クラスを定義し、providerriverpod などの状態管理パッケージでリビルドを制御するコードを書くのがこのレイヤーです。

BusinessLogic レイヤーに用意したクラスをどのように利用するかは設計次第です。私が開発するアプリでは StatefulWidgetState クラスが BusinessLogic レイヤーのオブジェクトを使う場合もありますし、アプリのさまざまな場所で共有したいデータは provider を使ってひとつの状態オブジェクトを共有します。時と場合に応じて適切なものを使うとよいでしょう。[9]

なお、クリーンアーキテクチャ本がおそらく想定している CLI アプリケーションにおいては、入力と出力は別々のコンポーネントが担当することになっているように読みましたが、アプリなどの GUI アプリケーションにおいては 入力するコンポーネントと出力するコンポーネントは同じ です。どちらも Widget クラスの build() メソッドに画面の表示内容もユーザーの操作も書かなければならないことを考えるとイメージしやすいと思います。

main.dart

最後に大事なのが main.dart です。クリーンアーキテクチャ本にも書いてある通り、全ての汚れ役は Main が担当 します。

今回のアプリでいう「汚れ役」とは、たとえば --dart-define の値に応じた Repository レイヤーの具象クラスの切り替えが挙げられます。

今回は get_it パッケージ を利用し、--dart-define の値に応じて UI から GetIt.I<RamenRepository>() のように呼び出した時に受け取れる具象クラスが切り替わるようにしています。

service_locator.dart
void prepare() {
  final mode = String.fromEnvironment('REPOSITORY_MODE');

  if (mode == 'server') {
    GetIt.I.registerSingleton<RamenRepository>(
      FirestoreRamenRepository(),
    );   
  } else {
    GetIt.I.registerSingleton<RamenRepository>(
      MockRamenRepository(),
    );   
  }
}

Firebase エミュレーターも利用したりしているため、エミュレーターを利用するための設定(FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8080) の呼び出しなど)もここで行います。

各レイヤー内に綺麗に閉じ込められない処理(主に準備処理)はここにまとめちゃうイメージです。main.dart にレイヤーや依存関係といった概念は存在しません。

まとめ

各レイヤー内のさらに詳しい設計など、書きたいことやおそらくみなさんが気になるであろうことはまだまだありますが、一旦長くなってしまったのでこの記事は以上とさせていただければと思います。

まだまだアーキテクチャについては勉強し始めですが(ちょっと前まで "DDD" がなんの略なのかもしらなかった)、まずはクリーンアーキテクチャ本の教えに従って「目の前の要件やプロジェクトの状況に応じた最適なアーキテクチャ」を常に考え続けることをこれからも継続していきたいと思います。

続き

実際に分割したそれぞれのレイヤーについて、詳しい説明を別の記事に書きました。続けて読んでみていただければと思います。

https://zenn.dev/chooyan/articles/17dde307509248

脚注
  1. 去年は「Flutter の内部実装」でした。 ↩︎

  2. ですので、「この設計、〇〇パターンのこの原則に違反してるじゃん」「この用語、△△パターンでの定義と違うじゃん」というような批判はご遠慮ください。そもそもそのようなパターンに準拠することを目的としていません。 ↩︎

  3. 実機では使えない、任意の位置情報をシミュレートできない、など。 ↩︎

  4. 自分は思いました。 ↩︎

  5. ちゃんとやるならパッケージ化も検討します。 ↩︎

  6. アーキテクチャについて考える前は「GPS の取得」と言ったらなんとなく View に含まれるのかなあ、と考えたりしていたのですが、よくよく考えると GPS の取得も「データの取得」なので Repository レイヤーに入れるのが自然なことに気がつきました。 ↩︎

  7. 「ビジネスロジック」というと「業務ロジック」と訳されがちですが、個人的にはこれは少し直訳すぎるかなと感じています。「業務」という言葉を言葉通りにとらえてしまうと、「じゃあ趣味アプリの場合は『業務』ロジックって存在しないの?」とちょっとした混乱が発生するためです。"business" という単語が、たとえば "It's my business.(それは私のやるべきことです)" や "It's not my business.(そんなん知ったこっちゃないよ、あなたの問題でしょ?)" みたいな使われ方をすることを考えてみると、「やりたいことを実現するための手順」 と考えるとイメージしやすいのではないかな、と考えています。 ↩︎

  8. flutter run でアプリを実行しての動作確認は、いくらホットリロードが便利な Flutter と言えども手間です。実行時にビルドエラーが出ることもありますし、PC のスペック次第ではビルドに数分かかることもありますし、実行できたとしてもお目当てのコードが動く画面まで進めなければなりません。その画面に到達する前に別のエラーが発生する場合もあるでしょう。ログも関係ないものがたくさん出て見づらいです。Dart 単体で動かせるコードというのは想像以上に扱いが楽であることが身に染みています。 ↩︎

  9. このあたりの状態管理まわりの使い分けや細かい設計については書き始めると長くなるため、別記事でまとめられればと思います。 ↩︎

GitHubで編集を提案

Discussion

神記事ありがとうございます!Logicまわりの実装方法を悩んでいたのでためになりました!

riverpodが入っている前提があるため気になってコメントしました。
riverpodのFutureProviderを使っている身からすると、この設計はかなり複雑のように見えます。
例えば データを取得してViewに反映する場合 view <- FutureProvider/StreamProvider <- repository層。

データをフロントから収集してpostするような場合なら
view -> StateNotifierProvider -> repository層

テストはRepository部分とModelのロジック部分を切り分けてモック作るなど。 model/entity層などしっかり作っていて必要なMethodが生えていればStateNotifierProvideを介せば事足りると思いました。(DBの定義の縛りがある場合はごめんなさい)
get_itも昔は便利でしたが riverpodにはProviderが備わっているので今は使う機会があまりないように思います。

コメントありがとうございます。

riverpodが入っている前提

これは少し誤解かもしれません。最後の UI の項目で書きたかったのは、それまでの設計で BusinessLogic や Repository は完全に Flutter から独立しているため、UI レイヤーの状態管理に何を採用しても全体のアーキテクチャには影響を与えないことを伝えようとしていました。

実際、ここで紹介しているアプリでも riverpod パッケージの導入は一旦断念し、作り直す前から使っていた provider パッケージで実装しています。(そのうち別途 riverpod に移行する予定ですが、その書き換えは UI レイヤーのみになるはずです)

view -> FutureProvider/StreamProviderからrepository層 -> model/entity層の流れで十分

今回のアプリの要件ではその流れでは「不十分」と判断したため BusinessLogic というレイヤーを挟んでいます。このあたりはアプリの要件やプロジェクトの状況次第ですので、「Flutter アプリだからこのレイヤーだけあれば十分」な使い回し可能なアーキテクチャーが存在するとは考えていないことが前提であることは記事にも書いた通りです。

(それぞれのレイヤーがなぜ必要なのかについては、確かに後半でもっと詳しく書ければと思いましたが、長くなってしまったため別記事で深掘りしていければと思います)

riverpod があれば get_it はいらない、というのは確かにその通りと思います。provider から riverpod に書き換える際に捨てられると考えています。

なるほど。
理解しました。一般的な作りではない前提で話をすすめると

BusinessLogic や Repository は完全に Flutter から独立しているため

もしこれを実現したいのであればバックエンド側でオーケストレーション層などを別途用意し、アプリ専用のデータ構造を返すのが良さそうでフロント側でやると複雑になるのかなぁとも思いました。なぜなら複数人開発するとテストも重複するしbuildも遅くなるため。

riverpod は公式通り従順に使う場合、特にFutureProviderなど従来の設計から外れる作りが多い(errorやローディングなど内包されていたり)ので

UI レイヤーの状態管理に何を採用しても全体のアーキテクチャには影響を与えないことを伝えよう

というのはとても難しい問題ではあります。

StatefulWidget の State クラスが BusinessLogic レイヤーのオブジェクトを使う場合もあります

あーすみません確かに書いてありましたね。
ただ、これがある時点で負債になるので、アーキテクチャ以前にStatefulWidget(vsyncとかどうしようもないのは除く)を潰すがもっとも最優先な気がしました💦 providerは入っているようですし。
setStateつらすぎますし状態管理が非常に難しい為。

コメントありがとうございます。

バックエンド側でオーケストレーション層などを別途用意

いいですね!今回は人的リソース的な問題でアプリ内で完結できる範囲でしか考えられませんでしたが、システム全体で最適化するのもやっていきたいですね。

StatefulWidget については、個人的には利用自体が負債になるとは考えていないです。たとえばその画面内でしか影響しない「選択肢のチェック状態」みたいなちょっとした状態は provider を通す方が冗長になりますし、BusinessLogic が返却するデータをそのまま build() で使うだけ、且つ他の Widget では絶対に使わない、みたいな場合もギリギリ StatefulWidget の方が楽だったりすることがあります。

確かに「ほとんど使い所はない」くらいの温度感ではありますが、とはいえ StatefulWidget が最適だと判断したら積極的に使うようにはしていますね。仕組み上、最短でリビルドを発生させられるのが setState() だったりもしますし。

なるほど、勉強になります。

BusinessLogic が返却するデータをそのまま build() で使うだけ、且つ他の Widget では絶対に使わない、みたいな場合もギリギリ StatefulWidget の方が楽だったりすることがあります。

riverpod使ってるとこういうのはすべて steteProvider へ寄せているので確かにそうかと思いました。
しばらく StatefulWidget を使用していなかったので役割も忘れていました。

フロントではjsonを組み立てるだけにしたい気持ちが高く、コメントしてしまいました。ありがとうございます!

大変考えさせられる記事でありがとうございます🙏🏻
Dependency inversion (依存性逆転)でかねがね気になっていた事をここで質問させてください。BusinessLogic 側で Repository のインターフェイスを持つのは良いのですが、もしもっとモジュール構造が複雑になって、例えばログインモジュールを独立モジュールとして切り離したくなったとします(ログイン機能は同じ会社の別アプリでも同様に使えるし、ビジネスロジックとも別)。ログインモジュールでもMemberRepository に対してインターフェイスを定義したくなる事があり得ます。この場合はどうすればいいのでしょうか?

そもそもログインモジュールがなくても、テストのdependency injection (依存性注入) のためにメインアプリでもMemberInterface は欲しくて、ビジネスロジック内のmemberinterfaceを使っても良いのですが、セマンティック的に気持ちが悪い気もします🤔

こちらこそコメントありがとうございます!

ログインモジュールでもMemberRepository に対してインターフェイスを定義したくなる

こちらですが、原則的にはログインモジュール側にも同じようなインターフェースを作ることになるのかなあ、と思います。とはいえおそらくこの場合、ログインモジュールと BusinessLogic では Repository に求めるインターフェースが変わってくるのではないでしょうか(ログインモジュールでは create / get のみ必要で、 update は不要?など)。そのため、別の依存される側の必要に応じたインターフェースをそれぞれ切るというのは自然なようにも思います。

その上で、もし具体的な処理が重複するようであれば、「インターフェースを実装するクラス(仮に LoginMemberFacade BusinessLogicMemberFacade と名付けます)」と「具体的な取得処理を行なうクラス( FirebaseMemberRepository とします)」を別にし、 XxxFacade は単純に FirebaseMemberRepository の必要なメソッドを呼び出すだけ、とすればコードの重複も抑えられるのではないかと思います。

ただし、ここまでやるのは当然それだけのメリットがある場合のみと思います。書いていただいた通りログインモジュールを別プロジェクトで使い回す、専属チームが開発できる、など、モジュールを分割して関連クラスを増やす手間よりも大きいメリットが得られる場合はここまで頑張ってもよさそうです。

テストのdependency injection (依存性注入) のためにメインアプリでもMemberInterface は欲しくて、ビジネスロジック内のmemberinterfaceを使っても良いのですが、セマンティック的に気持ちが悪い気もします🤔

こちらについては、記事に書いた「main.dart は全ての汚れ役を担当する」の話になるかと思います。DI を含むアプリ全体に影響を及ぼす処理は一切の依存関係のルールを無視して main.dart (もしくはそこから直接呼び出す別クラス)にまとめて書いてしまっています。ついでにファイル自体も lib/ 直下に置くことで、「このファイルは特別扱いでアーキテクチャのルール対象外である」ことをアピールしたりしています。

返信ありがとうございます。

確かに Facade とかを利用して重複処理を振り分ける事は出来そうですね。ただだいぶ構造としては複雑になる感じですね(このコードベースでFacade はどういう役割をする、とか初見の人の為には色々注意書きが必要になってくる感じ)🤔

実は返信待ちの間に自分でも色々調べながら考えていたのですが、"interface専用のモジュールを用意する"と言う方法もあるなあと思い至りました。図にすると以下の様になります。(汚くてすみません)

MemberRepositoryInterface を BusinessLogic 内に用意するのではなく、専用の RepositoryInterface に用意して、BL(BusinessLogic) もRepository もみんな RepositoryInterface に依存する形にすれば、やはり BL が Repository に依存しないようにできます。

DIP の wikipedia (https://en.wikipedia.org/wiki/Dependency_inversion_principle?wprov=sfti1) でも

"High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces).
高位のモジュールは低位のモジュールからインポートしてはいけない。双方が抽象に依存するべきである。"

と書いてあるので、その方がむしろ理念に近い気もしました。

ただもう完全にマイクロアーキテクチャみたいになって、これはこれでやり過ぎ感もありますね😅

どう思われるでしょうか?

たしかに、自分も今日クリーンアーキテクチャ本を読み返していて、似たようなことを考えたりしていました。今回のアプリで言うと、Data を各モジュールから共通利用する専用のモジュールとして切り出したのと発想は似ていますね。

とはいえこれも、ログイン以外のインターフェースは「BusinessLogic からしか使わないのに別モジュール(RepositoryInterface)に定義する」ことになるので、そのあたりが「やりすぎ」かどうかは判断ですね、、あとは共通にしちゃってあとあと問題にならないかどうかは Data の共通利用と同じことが言えそうです。

ログインするとコメントできます