Flutter+Riverpodのアーキテクチャ
執筆中
AIコーディング時代に、AIが適当なコードを書かないように、コーディング規約としてアーキテクチャを読み込ませておくことでより品質の高いコードを書かせられるようにしたい。あとテストがしやすくしたい。それと他の人がコードを理解しやすくしたい。他の人がコードを壊してくるのを防ぎたい
(小規模のアプリでかつ拡張予定もないようなアプリのことは考えない。そんなものはMVVMとかで適当に書いて終わり)
結論としては以前と変わらずレイヤードアーキテクチャ(クリーンアーキテクチャ)の考えを採用する。
層としては以下の4つ
Presentation層
Application層
Domain層
Infrastructure層
Domain層
UIに関係ない業務的なロジックを集中する場所
Entity
IDがあって、それによって同一性を保障するようなオブジェクトで、それ自身が業務ロジックを持つ。
Entityに公開されたメソッドを呼び出したらその内部状態が変わるなど、そのEntityに不整合が起こらないようになる必要がある。
例えばupdatedAtみたいな更新日時があるTaskを考えると、それ以外のメンバに値をsetするときには更新するようなロジックが書かれている必要がある。こうすることで他のメンバを更新したときには必ずupdatedAtが更新されるようになる。
code: task.dart
abstract class Task {
Task({
required this.id,
required this.name,
final List<Task> subTasks = const [],
final List<String> assigneeIds = const [],
}) : isCompleted = false,
_subTasks = subTasks,
_assigneeIds = assigneeIds,
assert(id.isNotEmpty, 'Task ID must not be empty'),
assert(name.isNotEmpty, 'Task name must not be empty');
final String id;
String name;
...
}
ただデータを入れるだけの入れ物はEntityとは言いづらい。
IDによって識別するのではなく、メンバの構成要素が変わることで変わるものはValueObject
ValueObject
例えば電話番号を表すclassとかは、電話番号が変わると別物として識別される
このようにIDを持たずに、そのメンバが変われば別物とするものがValueObject
code:phone_number.dart
class PhoneNumber {
final String value;
PhoneNumber(this.value);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PhoneNumber && value == other.value;
@override
int get hashCode => value.hashCode;
@override
String toString() => formatted;
}
Repository
Entityの永続化をする
Entityで保存する理由は整合性が担保された状態で保存するため
もしEntityの部分的な更新を行う場合は、設計を疑うか、Domain Serviceとして実装する
Service
Domain Entityとして表現できないロジックを表現する
Entityはそれ自身を管理しており、その外側のことは知らない
その外側におけるロジックがここ
例えば Entity が持つ ID が他とかぶっているかどうか、これは Entity 自身が知りようがないため、Serviceとして ID が存在するかをチェックするようなものができる
アプリケーション層
ユースケース
ドメインのロジックを使って、何をどうするという書き方をして、英語を読むように理解できるような処理を書く
~~Service.~~();
~~Repository.~~();
のように、ServiceやEntityのロジックを呼び出しつつRepositoryなどで永続化も命令する、といった感じになる
つまりDomainが定義されているならInfrastructureとして実装されていなくても実装できる
また、ここでエラーハンドリングを行うと良い
Domainで発生するエラーをここでハンドリングすることで、プレゼンテーション層では発生するエラーを全てハンドリングされている状態にでき、不明なエラーが UI で発生しない
fpdartにあるEitherを使って、そのユースケースで発生するExceptionと正常値を返すようにして、呼び出す側は result.match で適切に処理すると良い
code:sign_in_use_case.dart
abstract class SignInUseCase {
Future<Either<SignInUseCaseException, SignInUser>> call();
}
sealed class SignInUseCaseException implements Exception {
SignInUseCaseException();
factory SignInUseCaseException.internalServerError() = _InternalServerError;
factory SignInUseCaseException.networkError() = _NetworkException;
factory SignInUseCaseException.invalidParameters() = _InvalidParameters;
R match<R>({
required final R Function() internalServerError,
required final R Function() networkError,
required final R Function() invalidParameters,
}) {
return switch (this) {
_InternalServerError() => internalServerError(),
_NetworkException() => networkError(),
_InvalidParameters() => invalidParameters(),
};
}
}
final class _InternalServerError extends SignInUseCaseException {}
final class _NetworkException extends SignInUseCaseException {}
final class _InvalidParameters extends SignInUseCaseException {}
また、ユースケースにはqueryとcommandとして2パターンにわける
commandは上記の通り英語を記述するように実装するのでおおよそ大丈夫だが、
queryは返す値も検索の条件も UI のために変わりやすい
なので、query ユースケースでは実装はいきなり Infrastructure 層に書くのが良い(そもそもUI都合のqueryユースケースは業務的なロジックには直接関係ないので当然といえば当然)
また、DTO も使っておくと UI 都合で欲しい query 結果が変わってもそれを更新するだけで良くなる
DTO
Domain Entity とかをプレゼンテーション層で直接扱うのではなく、プレゼンテーション層との橋渡しになるオブジェクトを定義する。これがDTO(Data Transfer Objet)
これによって UI 都合で Domain Entity を変えられるといった無茶苦茶なことをされづらくなる
Riverpod使ってると変化通知のために copyWith をつけるのがよくあるけど、それを Domain Entity に実装するな
整合性が壊れる
プレゼンテーション層
Controller
Notifier
State
Widget
RiverpodのNotifierProviderはNotifierにロジックを持つことができて、自身のstateを更新することができるが、
これには2つの性質があり、混在しがちである。
ひとつはコマンドユースケース呼び出しや各種状態の連携など、UIロジック
ひとつはwidget側にwatchさせるstateそのものの管理
純粋なProviderなどは自分の状態を管理するためのコードだけで、基本的にUIロジックは書かれないので良いが、NotifierProviderはUIロジックを書けてしまう。
UIロジックとwidgetに描画するための状態管理を分離してしまいたい。
そもそも、Notifierに書かれるUIロジックについて、自分の持つstate以外の状態を変更しにいくのは責務が大きくなってしまっていることに加えて、そのロジックはどのNotifierに書かれるべきであるかが不明瞭である。
よってコントローラーとしてNotiferProvider<~~,void> として定義した、UIロジック専門のものを用意し、それは画面と1対1対応として定義することで、この画面におけるUIロジックはここ、という役割が明確になる。
さらには、UIロジックの単体テストを行うことができるようになる。
しかし、未だ課題はあり、BuildContextによってshowDialogやshowDatePickerなどを使ったダイアログ表示のUIロジック
これはダイアログはページのひとつとして扱ってGoRouteはProviderとして定義、それを参照する形でref.read(appRouter).goみたくやってしまうのが良い。
showDatePickerをページにしてしまうのが面倒、どうしよう
アプリケーション層はユースケースとDTO
ユースケースではドメインエンティティをDTOにしてプレゼンテーション層に渡す
DTOにはcopyWithが実装されていれば良い。
こうすればプレゼンテーション層でドメインエンティティを扱う必要はなくなる。
ドメインエンティティにcopyWithを実装するなどという暴挙は防ぐことができる。
逆にプレゼンテーション層からドメインエンティティへの入力情報もinputDTOとして扱うことで、最低限の情報をUIから入力するだけで良くなる。
UI側との問題といえば、UIの仕様変更とDB効率の問題。
それはCQRS(command query responsibility segregation)で対応する。
DBのクエリ効率はこれで問題なし。
保存のときは、集約ルートで保存するようにrepositoryを使う。
websocketとか、集約の一部を更新とかは、repositoryの責務ではなく、ドメインサービスの責務として実装する。
repositoryはあくまで集約ルートで更新して、集約の整合性が保たれるように更新すべきである。