前編である「導入編 」の続きです。
まずそちらをざっと一読されることをお勧めします。
kabochapo.hateblo.jp
ソースコード は本記事投稿後にたびたび改変しており、記事内容との相違があります。ご了承ください。
github.com
作るアプリについて(ご注意)
Flutter によるアプリ開発 でも DDD の恩恵があるのかを試すことと理解を深めることを目的とするサンプルであるため、アプリとして本来は考慮すべき点をいくつか無視しています。
データベース操作の効率や確実性にこだわっていない
メモのタイトルとカテゴリだけを使うときに本文まで取得している等。
更新や削除を行った結果の確認も省いています。
保存数に制限を設けていない
何万件ものメモを保存したときの動作は想定外かつ未確認です。
エラーメッセージの扱いが不十分
補足した Exception のメッセージをそのまま表示しています。
UI/UX は最低限
機能することを優先し、きれいで魅力的な見た目にしようとしていません。
あまりこだわらなくても標準装備のマテリアルデザイン のおかげでそれなりの UI になるのは Flutter の良いところですね。
実用について
実装に入っていく前に補足しておきます。
この記事のような設計に関して抽象化の部分をことさらに強調して無駄だ冗長だと否定する人がいますが、設計全体に対して抽象化は一部にすぎないのに全体が無駄かのように全否定していると柔軟性や保守性に欠けるソフトウェアになります。
その逆に、いま必要でないことまで何でもかんでも考慮した設計は過剰だとは思います。
全否定することと完全に抜けのない設計をすることは両極端すぎるので、私は間を取る考えです。
抽象化は必要になったときにすればいいという程度に捉えていて、長期的に継続開発できる、あるいはしばらく間が空いた後に改修するときにも困らない、といったことが自分の目指すところです。
私が実際に何か作るときには、保守しやすさを特に重視しながらこの記事の一部のみを取り入れています。
そこの塩梅は人それぞれなので、考えてみるといいと思います。
値オブジェクト(Value object)
ドメイン オブジェクトの一つです。
int のようなプリミティブな型を使うよりも、専用の型を用意したほうが良いという考え方です。
値オブジェクトは多くなりやすいので、「value 」というフォルダを作ることにしました。
ボトムアップドメイン駆動設計 では、値オブジェクトを作るモチベーションとして次の二つが挙げられています。
存在しない値を存在させない
class CategoryId {
final String value;
CategoryId (this .value)
: assert (value != null ),
assert (value.isNotEmpty) {
if (value == null || value.isEmpty) {
throw NullEmptyException ('Category ID' );
}
}
...
}
カテゴリ ID の値オブジェクトです。
コンストラク タにてバリデーション(null や空文字のチェック)も行っています。
これが非常に大事で、間違った値が入ってしまってからその値の使用箇所で検証するのではなく、そういった値がそもそも入ってしまうことを防ぐことができます。 *1
念のために assert()
も使っていますが、ここは見直し対象です…。
コンストラク タが不正な値を受け取ると、リリースモードでは if ブロックで例外をスローしますが、デバッグ モードではそこに到達する前に assert() で別のエラーが発生するため、モードによって例外の種類やメッセージが異なってしまいます。
値オブジェクトはベースになるものなのでしっかり作っておこうと思ったのですが、assert() しないほうがいいかもしれません。
→ assert() はその後やめました。
誤った代入を防ぐ
String 型のカテゴリ ID を受け取るメソッドがあるとします。
void hoge (String id)
このメソッドにカテゴリ ID 以外の文字列が渡されてもエラーになりません。
一方、仮引数に値オブジェクトを使う場合、CategoryId 型でないものが渡されるとコンパイル 時点でエラーになり、誤った代入を防げます。
また、コンパイル より前にも静的解析による警告がエディタに表示されます。
void hoge (CategoryId id)
IDE によっては記述時に引数の情報を表示してくれますが、CategoryId
というわかりやすい型名で表示されて String
より断然わかりやすくなるという利点もあります。
Dart には便利な「名前付き引数」があり、関数/メソッドに値を渡すときに引数名を指定させることができます。
void hoge ({String categoryId}) {
...
}
hoge (categoryId: 'カテゴリ ID の文字列' );
しかし、引数名は categoryId
になっているものの、型は String
のままです。
categoryId と名前指定しながら疲労 等によるミスで「カテゴリ 名 」を渡してしまってもエラーになりません。
また、カテゴリ ID の形式を検証する機能は String 型には当然ありません。
値オブジェクトを使いつつ、名前付き引数も併用するのが良いと思います。
値オブジェクトのルール
状態を不変に保つ
同じ値オブジェクト同士で値が等しいかどうかの確認ができる
完全に交換可能である(*2 )
簡単に言えば、値オブジェクトは String 等と同じ「値」ということです。
String s = 'foo' ;
s.changeTo ('bar' );
if (s == 'foo' ) {
s = 'bar' ;
}
値オブジェクトが「値」としてこれと同様の振る舞いとなるようにコードを書くことになります。
@immutable
class CategoryId {
final String value;
CategoryId (this .value) {
...
}
@override
bool operator == (Object other) =>
identical (other, this ) || (other is CategoryId && other.value == value);
@override
int get hashCode => runtimeType.hashCode ^ value.hashCode;
}
@immutable
のアノテーション を付け、フィールドを final
にする(*3 )
==
演算子 をオーバーライドして比較可能にする(*4 )
これでルールに沿うものになって「値」らしくなります。
こんな風に書くのは煩わしく感じられますが、不変なので「値」が誰かに書き換えられてしまう心配がなくなる等のメリットがあります。*5
等価チェックはどこで行うのか先にイメージできなかったものの、後で結局必要になりました。
箇所は少ないかもしれませんが、不要かもしれないと考えて省かずに必ず実装しておくのが良いと思います。
ルールを守るための改善案
equatable パッケージを使うと自動的に immutable になり、値オブジェクトを作るときのルールのミスを防げる上に、ボイラープレートを減らすことにもなります。
他の方法でもいいですが、記述の揺れや漏れを防ぐ工夫はチーム開発では特に大事だと思います。
エンティティ(Entity)
ドメイン オブジェクトの一つで、値オブジェクトに似ているけれど反対のような性質を持つものです。
可変
同じ属性でも区別される
同一性を持つ(*6 )
可変であり、何らかの識別子(ID など一意かつ不変のもの)によって区別します。
例えば下記はカテゴリのエンティティですが、カテゴリ名が同じでも同一と判断せず、カテゴリ ID で判断しています(other.id == id
のように識別子同士で比較します)。
class Category {
final CategoryId id;
CategoryName _name;
Category ({@required this .id, @required CategoryName name}) : _name = name;
CategoryName get name => _name;
@override
bool operator == (Object other) =>
identical (other, this ) || (other is Category && other.id == id);
@override
int get hashCode => runtimeType.hashCode ^ id.hashCode;
void changeName (CategoryName newName) {
_name = newName;
}
}
識別子は変わってはいけないので、それだけは final です。
それ以外のフィールドは必ずメソッド(上のコードでは changeName()
)を通して変更するので private にし、Getter を用意します。
CategoryName 等のバリデーションはその値オブジェクトのほうにメソッドがあるので、エンティティでは省きました。
*7
永続化
ボトムアップドメイン駆動設計 より:
Entity は同一性に着目するため、ライフサイクルが存在し、データベースやファイル等に永続化されることが多いです。
エンティティが永続化の対象になるようです。
つまり、リポジトリ (後述)が担当するのはエンティティの状態を保存したり取り出したりすることだと思っておけば良さそうです。
可変ゆえの悩み
もし Note が Category のデータを持っていると、次のように Note から Category のメソッドを利用して Category が保持するカテゴリ名を変更できてしまいます。
後ほど集約のところで説明しますが、これはアンチパターン です。
note.category.changeName ('hoge' );
このような操作をしないこと!という決まりを開発メンバー間で作っておいても操作しようと思えばできてしまうので、させないよう制限する方法が必要です。
Note が持つ Category を隠蔽する
ただし、それを使う範囲からのアクセスのみを許可する工夫が必要です。(*8 )
Note が持つ Category を隠蔽し、複製データを得るための Getter を用意する
複製を変更してもエンティティが持つ元のデータに影響が及びません。
読み取り専用の値を保持対象にする
ミュータブルな Category ではなくイミュータブルな CategoryId を持たせれば安全です。
通知パターンを使う
ボトムアップ ドメイン 駆動設計では、User のエンティティ自体ではなく読み取り専用の UserId のリストを Circle で保持し、更に通知パターンも使っています(上記の三つ目と四つ目の組み合わせ)。
メモアプリでは、通知パターンを使うとややこしくなるので三つ目だけにしました。
集約(Aggregate)
整合性を保たないといけないトランザクション の範囲を一つのまとまりとして表した単位です。
メモアプリでは「カテゴリ」と「メモ」がそれぞれ個々の集約です。
集約には次のような決まりがあります。
ルートとなるエンティティが各集約に一つだけある(集約ルート)
集約ルートのみが集約内の他のドメイン オブジェクトを操作可能
アプリケーションサービスから操作できるのは集約ルートのみ
カテゴリとメモはエンティティを一つずつしか持っていないので、その一つずつが集約ルートです。
集約に含むもの
どうやら値オブジェクトとエンティティだけのようです。
おそらくドメイン サービスは含みません。
アプリケーションサービスは集約ルートが持つ他のオブジェクトを取り出して操作してはいけません。
デメテルの法則 というものです。
上記リンク先の Wikipedia の記事より:
あるオブジェクトAは別のオブジェクトBのサービスを要求してもよい(メソッドを呼び出してもよい)が、オブジェクトAがオブジェクトBを「経由して」さらに別のオブジェクトCのサービスを要求してはならない。これが望ましくないのは、オブジェクトAがオブジェクトBに対して、オブジェクトB自身の内部構造以上の知識を要求してしまうためである。
先ほどの Note に Category を持たせるアンチパターン はこのことです。
アプリケーションサービス(オブジェクトA)で Note(オブジェクトB)のメソッドを呼んでもいいけれど、Note を経由して Category(オブジェクトC)のメソッドを呼び出すのは NG ということです。
そんな不自然な操作をしないといけなくなっているなら、設計がおかしいと考えられます。
その操作のメソッドを Note に持たせるだけで解決できることもありますので、おかしいと感じたら見直してみましょう。 *10
集約の範囲(整合性の境界)
メモアプリはボトムアップ ドメイン 駆動設計をベースにしたため、単純に「ユーザとサークル」を「メモとカテゴリ」に置き換えて考えることができましたが、自分で集約の範囲を決めることになったら少し難しいですね。
先ほど書いた「整合性を保たないといけないトランザクション の範囲」が集約の決め方の基本だと考えて良いはずですが、それだけでは不慣れな私にとっては判断に不十分です。
例えば、あるウェブサービス で会員登録を行うとき、登録によるボーナスポイント付与までがトランザクション の範囲だったら、会員データ関連とポイント関連が同じ集約に含まれることになるのでしょうか…?
明確に判断できるよう、「実践ドメイン 駆動設計」などで理解を深めないといけないなと思います。
ドメイン サービス(Domain service)
エンティティに関係するロジックであるけれども、エンティティに実装すると違和感を産むロジックは必ず存在します。
そういったロジックを受け持つものとしてドメイン サービスが存在します。 *11
ボトムアップ ドメイン 駆動設計では、ユーザが自身の重複を確認するのは不自然ということで、重複確認のメソッドをエンティティではなくドメイン サービスに持たせています。
メモアプリのメモやカテゴリの重複確認も同様にしました。
class NoteService {
...
Future < bool > isDuplicated (NoteTitle title) async {
final searched = await _repository.findByTitle (title);
return searched != null ;
}
}
ドメイン サービスに書くか否か
カテゴリ名変更のメソッドについても「カテゴリという無機質なものが自身の名前を変える」という点に違和感を覚えました。
しかし先ほどの「重複の有無を自分に尋ねる」という不自然さとは異質に思えます。
「生物でもないものが自分で名前を変える」ことは擬人化すればどうにか成立しますが、「自分が知らないこと(重複しているかどうか)を自分に尋ねる」ことはどうしても矛盾があるので、その違いかなと考えました。
それでも迷いが消えなかったため、ボトムアップドメイン駆動設計 の下記アドバイス に従って、カテゴリ名変更はエンティティのほうにしました。
ドメイン サービスとエンティティのどちらにロジックを記述するか迷ったときはエンティティに記述してください。
ドメイン サービスと値オブジェクトのどちらにロジックを記述するか迷ったときは値オブジェクトに記述してください。
ドメイン サービスは必要最小限にすることを心掛けましょう。
リポジトリ (Repository)
ドメイン オブジェクトに永続化の処理を書くと、その記述ばかりになってしまったりします。
また、ドメイン オブジェクト内に直接書くとデータベースに強く依存してしまいます。
そうならないように「リポジトリ 」として分けて書きます。
リポジトリ を置く場所は 導入編 に書いたとおり 依存関係逆転の法則
によってインタフェースがドメイン 層、実装がインフラ層になると考えていましたが、Qiita の記事 のコメントによると実装のほうはどこでも良いそうです。
iDDDを確認したところ、「インタフェースはドメイン 層に作る。実装はどこでもいい。インフラ層でもいい(=つまりインフラ層じゃなくてもいいってことですね)」と書いてありました。
これも iDDD 本(実践ドメイン 駆動設計)で示されているのですね。
やはりちゃんと読まないと…。
ボトムアップドメイン駆動設計 より:
リポジトリ は集約毎に用意します。
整合性の単位が集約ですから、集約に対応するようにリポジトリ を定義しておけば変更を過不足なく永続化できるからです。
ということで、メモアプリでは「カテゴリ」と「メモ」のそれぞれの集約分を用意しました。
松岡さんの記事(「DDDのモデリングとは何なのか、 そしてどうコードに落とすのか」資料 / Q&A - little hands' lab )に書かれている Q&A がわかりやすいです。
「ApplicationService からではなく、Entity から Repository を利用して、DBからの取得や更新などの操作をしても良いものでしょうか?」という質問に対する答えが下記です。
entityが複数の責務を持つことになるので、オススメしません。一般的に責務が増える、ということは凝集度が下がり、可読性やメンテナンス性を下げます。
リポジトリ をアプリケーションサービスから利用しなければならないというよりは、エンティティが複数の責務を持たないほうがいいというアドバイス のように読めますが、アプリケーションサービスから利用しておけばまず大丈夫だろうと捉えました。
なお、ボトムアップ ドメイン 駆動設計ではドメイン サービスからもリポジトリ を利用(*12 )していて、メモアプリでも真似しました(それが良いのかどうか自分では判断できていません)。
Dart におけるインタフェース
過去には Dart にもキーワードとして interface
があったようです(*13 )が、今はありません。
代わりに「暗黙的インタフェース」(Implicit interface )があり、クラスを作ればインタフェースとしても機能するという便利なものです。
もう一つの代替が「抽象クラス」(Abstract class )です。
メモアプリではこちらを使いました。
カテゴリ集約のリポジトリ の抽象クラスは下記のようになっています。
abstract class CategoryRepositoryBase {
Future < T > transaction < T > (Future < T > Function () f);
Future < Category > find (CategoryId id);
Future < Category > findByName (CategoryName name);
Future < List < Category >> findAll ();
Future < void > save (Category category);
Future < void > remove (Category category);
}
なお、Dart ではメソッドのオーバーロード ができないので名前で区別しています。
用途の違いが名前でわかるので、それはそれで良いなと思います。
インタフェース名
言語や開発チーム等によってインタフェースに接頭辞 I
を付けたり、実装クラスのほうに接尾辞 Impl
を付けたりすることが多いですよね。
ボトムアップ ドメイン 駆動設計ではインタフェースに I
を付けていますが、メモアプリでは Base
という接尾辞を付けることにしました(「~Interface」でも良いですが、少し長いなと思いました)。
好みの問題です。
実装クラスのほうに付けなかったのは、インタフェース(抽象クラス)なのを明確にすることで DI しようとしていることを伝わりやすくしたかったからです(get_it を使っていることからも明白ですが)。
class NoteAppService {
final NoteRepositoryBase _repository = GetIt .instance < NoteRepositoryBase > ();
...
}
しかし、命名規則 はチームで取り決めておいて一貫性が保てれば良いでしょう。
実装
class CategoryRepository implements CategoryRepositoryBase {
...
@override
Future < T > transaction < T > (Future < T > Function () f) async {
return _dbHelper.transaction < T > (() => f ());
}
@override
Future < Category > find (CategoryId id) async {
final list = await (_dbHelper.txn ?? await _dbHelper.db).rawQuery (
'SELECT * FROM categories WHERE id = ?' ,
< String > [id.value],
);
return list.isEmpty ? null : toCategory (list[0 ]);
}
...
後ほどトランザクション のところで少し解説します。
ファクトリ(Factory)
オブジェクトの生成を担うものです。
アプリケーションサービスでカテゴリやメモのオブジェクトを生成するのに使っています。
Dart には factory
というキーワードがあって紛らわしいですが、別物です。
これを用意する理由が少し掴みにくいところですが、「現場で役立つシステム設計の原則」の 増田さんのブログ記事 がわかりやすいです。
エンジンが完成した後は、エンジンの責務は、シャフトを回転させること。
このエンジンオブジェクトが、組立の知識を持っているのは変でしょ? という話し。
(中略)
エンジンオブジェクトを生み出すための知識・責務は、別のオブジェクトに持たせて、エンジンオブジェクトは、シャフトを回す自分の本来の責任だけに専念すべき。
メモアプリであれば、メモを作る処理をメモ自体に持たせずにメモ工場に委譲するということです。
この他に、生成処理が分離されてテストしやすくなる効果もあります。
例えば ID を DB で自動採番するアプリであっても、テストでは DB を使わない採番方法に差し替えやすくなります。
そのためファクトリもリポジトリ のように、ドメイン 層にインタフェース(抽象クラス)、インフラ層に実装を置いています(が、メモアプリでは環境に依存しない Uuid()
を採番に使っていてテストでも使い回せるので、テスト専用の実装はしていません)。
抽象クラス
abstract class CategoryFactoryBase {
Category create ({@required String name});
}
実装
class CategoryFactory implements CategoryFactoryBase {
@override
Category create ({@required String name}) {
return Category (
id: CategoryId (Uuid ().v4 ()),
name: CategoryName (name),
);
}
}
アプリケーションサービス(Application service)
ようやくドメイン 層の話が終わり、アプリケーション層(ユースケース 層)に移ります。
ユースケース 図に書いた「ユーザとアプリケーションの相互作用」を実装して業務の問題を解決する(ユースケース を満たす)部分がアプリケーションサービスだと思います。
基本的にドメイン 層に用意したものを使うだけです。
例えば下記はカテゴリを登録するメソッドで、カテゴリのエンティティを生成するファクトリ、名前の重複を確認するドメイン サービスのメソッド、DB に保存するリポジトリ のメソッド、といった用意済みのものを組み合わせています。
ぱっと見て、何をしているか把握しやすいのではないでしょうか。
こんなにシンプルなのに、カテゴリ名のバリデーションの機能まで備えています。
Future < void > saveCategory ({@required String name}) async {
final category = _factory.create (name: name);
await _repository.transaction < void > (() async {
if (await _service.isDuplicated (category.name)) {
throw NotUniqueException ('Category name: ${category.name.value} ' );
} else {
await _repository.save (category);
}
});
}
なお、上記以外の部分に一部ロジックっぽいもの(消そうとしたカテゴリの有無による分岐など)もありますが、ドメイン 層で行うことではないからです。
ファクトリとリポジトリ は DI できるように抽象クラスの型にしておきます(ドメイン サービスは直に依存して良いものなのでアプリケーションサービス内で生成)。
ファクトリは直前に生成してコンストラク タで受け取りますが、リポジトリ は main.dart
で生成していて受け渡しが大変なので get_it
を使っています。
class CategoryAppService {
final CategoryFactoryBase _factory;
final _service = CategoryService ();
final _repository = GetIt .instance < CategoryRepositoryBase > ();
final _noteRepository = GetIt .instance < NoteRepositoryBase > ();
CategoryAppService ({@required CategoryFactoryBase factory })
: _factory = factory ;
...
}
DTO (Data Transfer Object)
アプリケーションサービス内で取得したカテゴリやメモのデータをそのままプレゼンテーション層に返すと、それらのエンティティが持つメソッドを UI 側で使えてしまいます。
ボトムアップ ドメイン 駆動設計では
プロジェクトのポリシーによりますが、この例ではドメイン 領域の知識が流出しないように DTO (Data Transfer Object)を用意する方針で記述します。
とのことでしたのでそれに倣いました。
メソッドは無く、使う側で必要とされる情報だけを持たせています。
class NoteDto {
final String id;
final String title;
final String body;
final String categoryId;
NoteDto (Note source)
: id = source.id.value,
title = source.title.value,
body = source.body.value,
categoryId = source.categoryId.value;
}
アプリケーションサービスから返すときに、DB から取得したデータを基に DTO を作ります。
Future < NoteDto > getNote (String id) async {
final targetId = NoteId (id);
final target = await _repository.find (targetId);
return target == null ? null : NoteDto (target);
}
クラスの命名 は少し悩みました。
元のオブジェクトと区別しやすい何かを名前に付けるのが良いと思いますが、XxxxDto
はちょっとダサいかなと思いつつ、他に良いものが浮かばなかったのでそれにしました。
テスト
松岡さんの記事(「DDDのモデリングとは何なのか、 そしてどうコードに落とすのか」資料 / Q&A - little hands' lab )にある Q&A には、「ドメイン 駆動設計で作ったアプリケーションに対して単体テスト を行うとき、意図して取り組んでいることはありますか?」という質問とその答えが載っています。
基本application層で結合テスト を書きます。 ドメイン 層のentityなどは複雑になったら必要に応じて書くようにしています。 テストのROIを考慮して色々試した結果それが一番よかったためです。
また、ボトムアップドメイン駆動設計 でも次のように書かれています。
テストを書く単位はアプリケーションサービスのメソッドを単位としてテストを記述すると、ちょうどビジネスロジック 毎のテストになってよいかと思います。
結合テスト と単体テスト という意見の違いはあるようです(*14 )が、アプリケーション層のテストが良いという点はお二人が一致されているので、それを採り入れるのがとりあえず最善に思えます。
メモアプリでは、既存カテゴリを変更していなくても重複扱いになって保存失敗してしまっていたのを開発途中で直し、そのときに下記のテストを追加しました。
アプリケーションサービスのいくつかのメソッドを使ったテストになっています。
test ('update without change should be successful' , () async {
final app = CategoryAppService (factory : const CategoryFactory ());
await app.saveCategory (name: 'category name' );
final categories = await app.getCategoryList ();
bool isSuccessful = true ;
try {
await app.updateCategory (
id: categories[0 ].id,
name: 'category name' ,
);
} catch (_) {
isSuccessful = false ;
}
expect (isSuccessful, true );
});
Flutter では Widget のテストも行えるので、UI の操作~状態変更~表示更新までテストするとより良いですが、DDD のサンプルですのでそこまでやっていません。
わかっていない点
アプリケーションサービスは集約のうちルートしか操作できないということでしたが、NoteId
のような値オブジェクトをアプリケーションサービスの中で生成しています。
この矛盾がどういうことなのか不明です。
生成するだけなら OK で、メソッドを使ってはいけないということなのでしょうか…?
データベース関連処理
データベースの扱い方についてサンプルを参考になさる方のために、簡単に解説しておきます。
メモアプリでは SQLite を使っています(sqflite
パッケージを使用)。
O/R マッパーを使うと設計が変わってくるかもしれませんが、本記事では取り扱いません。
DB を扱う準備
複数のリポジトリ があっても使うデータベースは同じだったりして、リポジトリ ごとにオープンするのは無駄です。
そこで、DB のオープン/クローズや初期化を扱うクラス(DbHelper)を作り、Widget ツリーのルートに近いところで Provider の create
で生成して使い回すようにしました。
クローズも Provider
がやってくれます。
リポジトリ も DbHelper と同じタイミングで生成しますが、クローズ/破棄のような後始末が不要なので get_it
でインジェクトしています。
*15
Provider < DbHelper > (
lazy: false ,
create: (_) {
final helper = DbHelper ();
final getIt = GetIt .instance;
getIt.registerSingleton < CategoryRepositoryBase > (
CategoryRepository (dbHelper: helper),
);
getIt.registerSingleton < NoteRepositoryBase > (
NoteRepository (dbHelper: helper),
);
return helper;
},
dispose: (_, helper) => helper.close (),
child: const CategoryListPage (),
)
DbHelper の close()
では DB がオープンしていないことも考慮して _db?.close()
としています。
また、_db
が null かどうかでオープンするか判断しているので、クローズした時には null に戻しておきます。
class DbHelper {
Database _db;
Future < Database > get db async {
if (_db != null ) {
return _db;
}
...
_db = await openDatabase (
...
);
return _db;
}
Future < void > close () async {
await _db? .close ();
_db = null ;
}
...
}
永続化データの復元
DB からデータを取り出して Category や Note のエンティティに戻す処理をエンティティ自体に書きそうになりましたが、DB のカラム構造に依存するメソッドを持たせるべきではないと気づいてやめました。
代わりに、次のようにカラムの値を取り出してエンティティに変えるメソッドをリポジトリ のほうに書き、find()
等の取得メソッドでデータを返却するときにそれを使ってエンティティとして復元しています。
Category toCategory (Map < String , dynamic > data) {
final String id = data['id' ].toString ();
final String name = data['name' ].toString ();
return Category (
id: CategoryId (id),
name: CategoryName (name),
);
}
...
Future < Category > find (CategoryId id) async {
final list = await (_dbHelper.txn ?? await _dbHelper.db).rawQuery (
'SELECT * FROM categories WHERE id = ?' ,
< String > [id.value],
);
return list.isEmpty ? null : toCategory (list[0 ]);
}
リポジトリ の中で CategoryId 等エンティティを使っている(ドメイン オブジェクトのうち集約ルートでもないものを扱っている)ことが不安ですが、ボトムアップ ドメイン 駆動設計でもそうなっているので良しとしました。
一連のDB操作の途中で失敗したときや、複数のユーザが同時に操作したときなどのために、トランザクション は必須です。
ボトムアップドメイン駆動設計 では、テーブルのカラムに UNIQUE にすれば重複登録 を失敗させることができるもののソースコード 上でそのことがわかりにくいという問題点があり、別の方法が必要だけれど言語によっては一工夫必要になる旨が説明されています。
Dart にはトランザクション スコープはありませんが、Flutter で使える sqflite では
db.transaction ((txn) async {
});
のようにしてトランザクション を扱えるようになっていて、少し苦労しましたがトランザクション スコープに近い書き方ができました。
下のコードのように DbHelper に db.transaction()
を利用するメソッドを用意し、リポジトリ からそれを呼び出せるようにします(アプリケーションサービスは DbHelper に依存してはいけないためリポジトリ 経由で使います)。
class DbHelper {
Database _db;
Transaction _txn;
Transaction get txn => _txn;
Future < T > transaction < T > (Future < T > Function () f) async {
return db.then ((db) async {
return db.transaction < T > ((txn) async {
_txn = txn;
return f ();
}).then ((v) {
_txn = null ;
return v;
});
});
}
}
class CategoryRepository implements CategoryRepositoryBase {
...
@override
Future < T > transaction < T > (Future < T > Function () f) async {
return _dbHelper.transaction < T > (() => f ());
}
...
}
トランザクション が始まったときに DbHelper のフィールド(_txn
)にそのトランザクション のインスタンス を入れ、終わったら null にしています。
リポジトリ では _txn
に値があればその値(トランザクション のインスタンス )、null ならデータベースのインスタンス を使ってデータベースの操作を行います。
下記の (_dbHelper.txn ?? await _dbHelper.db)
という部分がそれです。
※2020/3/14 更新
txn と db の使い分けを DbHelper で行うようにし、db(getter)を _open()
というライブラリプライベートなメソッドに変えました。
*17
最新のコードは リポジトリ をご覧ください。
class CategoryRepository implements CategoryRepositoryBase {
...
@override
Future < Category > find (CategoryId id) async {
final list = await (_dbHelper.txn ?? await _dbHelper.db).rawQuery (
'SELECT * FROM categories WHERE id = ?' ,
< String > [id.value],
);
return list.isEmpty ? null : toCategory (list[0 ]);
}
...
アプリケーションサービスではトランザクション が必要な部分を _repository.transaction<戻り値の型>(() async { トランザクションが必要な処理 })
のように囲うだけです。
class CategoryAppService {
...
Future < void > saveCategory ({@required String name}) async {
final category = _factory.create (name: name);
await _repository.transaction < void > (() async {
if (await _service.isDuplicated (category.name)) {
throw NotUniqueException ('Category name: ${category.name.value} ' );
} else {
await _repository.save (category);
}
});
}
...
DbHelper の _txn
に入っている値によってトランザクション かどうかが変わるので、並行的な DB 処理がある場合には使えませんが、逐次であればこれで足りるかと思います。 *18
なお、sqflite のトランザクション ではロールバック は意図的にできず、db.transaction((txn) async { ... })
の中で処理が失敗したときに勝手にロールバック されるようです。
コミットも自動です。
プレゼンテーション層
残りわずかです。
Notifier
導入編 で書いたとおり、ChangeNotifier
を継承したクラスをモデルと呼ぶことにして presentation/model/
というパスに置いています。
クラス名も XxxxModel
としています。
XxxxNotifier
にしてフォルダ名も「notifier」で良かったんじゃないかと今思いますが、簡単に変えられるところなのでご自身の好みで変えていただければと思います。
ChangeNotifier
を Mixin したクラスであって「notifier」のほうがわかりやすいので後で変えました(上のスクショは元のままです)。
ここの役割は下の層への入口や橋渡しのようなものであり、かつ、状態を持っていてその変更が通知されて Widget のリビルドが走るというものです。
ではコードを見てみましょう。
class CategoryNotifier with ChangeNotifier {
final CategoryAppService _app;
CategoryNotifier ({@required CategoryAppService app}) : _app = app {
_updateList ();
}
List < CategoryDto > _list;
List < CategoryDto > get list => _list == null ? null : List .unmodifiable (_list);
Future < void > saveCategory ({
@required String name,
}) async {
await _app.saveCategory (name: name);
_updateList ();
}
Future < void > updateCategory ({
@required String id,
@required String name,
}) async {
await _app.updateCategory (id: id, name: name);
_updateList ();
}
Future < void > removeCategory (String id) async {
await _app.removeCategory (id);
_updateList ();
}
void _updateList () {
_app.getCategoryList ().then ((list) {
_list = list;
notifyListeners ();
});
}
}
このように、ほぼアプリケーションサービスのメソッドを使うだけです。
UI 層での操作をきっかけに、このモデルを通してアプリケーション層、ドメイン 層のメソッドが呼び出されていってドメイン オブジェクトの処理によって状態が変わり、その変更を notifyListeners()
で通知しています。
少し気を配ったのは次の部分です。
List < CategoryDto > get list => _list == null ? null : List .unmodifiable (_list);
DTO はデータ返却専用のオブジェクトであり、中身は final なので変更できないのですが、返されたリストを受け取った側で要素を差し替えることはできてしまいます。
それを不可能にするために List.unmodifiable()
を使いました。
*19
*20
DDD に取り組み始めてからそういった安全意識が高まってきた実感があります。
このように設計全般に対して考え方を改善できるという意味でも DDD を学ぶのは有益だと思います。
他の部分
プレゼンテーション層の他の部分については DDD 関連で特筆するところはありません。
下の層をしっかりと作っておくことで、いつの間にかちゃんと機能するものが出来上がっていたという印象です。
例えば、バリデーションやそのエラー(例外)の処理をドメイン 層で先に済ませているので、カテゴリ名等を編集するダイアログでは例外時にエラー表示するように書くだけです。
下ごしらえがしっかりとできている3分クッキングのようです。
機能を変えるときにもおそらく下ごしらえのほうを局所的に変更すれば済むと思います。
アプリ開発 の途中に混乱してしまうことが多い人は、ぜひ DDD を取り入れてみてください。
改善の余地
コレクションオブジェクト(ファーストクラスコレクション)
ドメイン 層でコレクション(List や Map)を扱う必要がある場合、それをオブジェクトにすると、例えば List 内の値を合計するといった処理をメソッドとして提供できて扱いやすくなります。
メモアプリでは作る必要がなかったのですが、他のアプリを使うときにコレクションオブジェクトを作ると助かる場合があることを覚えておくと良いと思います。
export
ファイルのエクスポートをしているところが多数あります。
例えば domain/note/note.dart
は下記のようになっています。
そうしておけば、note.dart
をインポートしたファイルで note_body.dart
等のインポートを省いても NoteBody
等を使うことできて楽です。
import 'package:meta/meta.dart' show required;
import 'package:flutter_ddd/domain/category/value/category_id.dart' ;
import 'package:flutter_ddd/domain/note/value/note_body.dart' ;
import 'package:flutter_ddd/domain/note/value/note_id.dart' ;
import 'package:flutter_ddd/domain/note/value/note_title.dart' ;
export 'package:flutter_ddd/domain/category/value/category_id.dart' ;
export 'package:flutter_ddd/domain/note/value/note_body.dart' ;
export 'package:flutter_ddd/domain/note/value/note_id.dart' ;
export 'package:flutter_ddd/domain/note/value/note_title.dart' ;
しかし、8月末にあった DDD のイベント で「インポートの記述を見ればおかしな依存をしていないか判断できる」というような話があり、なるほどと思いました。
エクスポートしてしまうとインポートを省略して依存のミスに気づきにくくなってしまいそうです。
そう考えるとエクスポートしないほうが良いかもしれません。
やはり「実装ドメイン 駆動設計」
この記事を書きながら、曖昧にしか理解できていないところがまだ多いと気づきました。
調べていくと実践ドメイン 駆動設計の本から引用された情報に辿り着くことが多く、やはりその本を読むのが近道だろうなと感じました。
高価なものですが、他の人の評価も高くて価値があるようです。
おわり!
長文をお読みいただきありがとうございました。
間違いなどありましたらどうぞお知らせくださいませ。