Location via proxy:   [ UP ]  
[Report a bug]   [Manage cookies]                

のんびり精進

調べた情報などをおすそ分けできれば。

Dart/Flutterでドメイン駆動設計(DDD)してみた - 実装編

前編である「導入編」の続きです。
まずそちらをざっと一読されることをお勧めします。

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 の名前付き引数

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;
}

これでルールに沿うものになって「値」らしくなります。

こんな風に書くのは煩わしく感じられますが、不変なので「値」が誰かに書き換えられてしまう心配がなくなる等のメリットがあります。*5

等価チェックはどこで行うのか先にイメージできなかったものの、後で結局必要になりました。 箇所は少ないかもしれませんが、不要かもしれないと考えて省かずに必ず実装しておくのが良いと思います。

ルールを守るための改善案

equatable パッケージを使うと自動的に immutable になり、値オブジェクトを作るときのルールのミスを防げる上に、ボイラープレートを減らすことにもなります。 他の方法でもいいですが、記述の揺れや漏れを防ぐ工夫はチーム開発では特に大事だと思います。

エンティティ(Entity)

ドメインオブジェクトの一つで、値オブジェクトに似ているけれど反対のような性質を持つものです。

  • 可変
  • 同じ属性でも区別される
  • 同一性を持つ(*6

可変であり、何らかの識別子(ID など一意かつ不変のもの)によって区別します。

例えば下記はカテゴリのエンティティですが、カテゴリ名が同じでも同一と判断せず、カテゴリ ID で判断しています(other.id == id のように識別子同士で比較します)。

class Category {
  // 同一性の判断に用いる ID は不変
  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 を持たせれば安全です。
  • 通知パターンを使う
    • ただし、処理が煩雑になります。( *9

ボトムアップドメイン駆動設計では、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 ツリーのルートに近いところで Providercreate で生成して使い回すようにしました。 クローズも Provider がやってくれます。

リポジトリも DbHelper と同じタイミングで生成しますが、クローズ/破棄のような後始末が不要なので get_it でインジェクトしています。 *15

Provider<DbHelper>(
  lazy: false,
  create: (_) {
    // DbHelper を生成
    final helper = DbHelper();

    // DbHelper を渡してリポジトリを生成
    final getIt = GetIt.instance;
    getIt.registerSingleton<CategoryRepositoryBase>(
      CategoryRepository(dbHelper: helper),
    );
    getIt.registerSingleton<NoteRepositoryBase>(
      NoteRepository(dbHelper: helper),
    );

    return helper;
  },
  // この Provider がツリーから除去されたときに DB クローズ
  dispose: (_, helper) => helper.close(),
  child: const CategoryListPage(),
)

DbHelper の close() では DB がオープンしていないことも考慮して _db?.close() としています。 また、_db が null かどうかでオープンするか判断しているので、クローズした時には null に戻しておきます。

class DbHelper {
  Database _db;

  // この Getter にアクセスしたときにクローズ状態ならオープンする
  Future<Database> get db async {
    if (_db != null) {
      return _db;
    }

    ...

    // オープンしていないときだけここに到達
    _db = await openDatabase(
      ...
    );

    return _db;
  }

  Future<void> close() async {
    await _db?.close();
    // null に戻すことでオープンしていないことを示す
    _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 {
  // この中で txn を使って一つのトランザクションとして扱える
});

のようにしてトランザクションを扱えるようになっていて、少し苦労しましたがトランザクションスコープに近い書き方ができました。

下のコードのように 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) {
        // 終了時に null に戻す(= トランザクション中でないことを示す)
        _txn = null;
        return v;
      });
    });
  }
}
class CategoryRepository implements CategoryRepositoryBase {
  ...

  // アプリケーション層からは DbHelper のメソッドを直接使わずにこれを経由する
  @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 のイベント で「インポートの記述を見ればおかしな依存をしていないか判断できる」というような話があり、なるほどと思いました。

エクスポートしてしまうとインポートを省略して依存のミスに気づきにくくなってしまいそうです。 そう考えるとエクスポートしないほうが良いかもしれません。

やはり「実装ドメイン駆動設計」

この記事を書きながら、曖昧にしか理解できていないところがまだ多いと気づきました。 調べていくと実践ドメイン駆動設計の本から引用された情報に辿り着くことが多く、やはりその本を読むのが近道だろうなと感じました。 高価なものですが、他の人の評価も高くて価値があるようです。

おわり!

長文をお読みいただきありがとうございました。
間違いなどありましたらどうぞお知らせくださいませ。

*1:必ずしも一ヶ所で済ませるのが良いわけではないようです。ドメイン層と UI 層の両方で行うのは悪くないという話が DDD Radio 第2回公開収録 というイベント内で出ていました(音声はそのうち こちら に上がると思います)。ドメイン層は防御、UI 層は UX のためという目的の違いがあるとのことです。確かにそうですね。

*2:ボトムアップドメイン駆動設計 より引用。

*3:@immutable のアノテーションを使うには package:meta/meta.dart のインポートが必要です。

*4:この比較において、渡されたオブジェクトが null でないことも確認しようと思いましたが、analysis_options.yaml で avoid_null_checks_in_equality_operators を有効にしていると「Don't check for null in custom == operators.」の警告が出るのでやめました。

*5:詳しくは ボトムアップドメイン駆動設計 をお読みください。

*6:ボトムアップドメイン駆動設計 より引用。

*7:ボトムアップドメイン駆動設計では User エンティティの ChangeUserName() 等でもバリデートされていますが、二重に検証することになるのでメモアプリでは避けました。

*8:Dart ではアクセス許可したい範囲を同一ライブラリにすれば library private となって可能ですが、part の記述が手間です。また、そのような構造にした理由が伝わるように管理しておく煩わしさも出てくると思います。

*9:これらの箇条書きだけでは伝えきれませんので、ボトムアップドメイン駆動設計GitHub のプロジェクト をご覧ください。

*10:AggregateRoot - 集約 │ nrslib がわかりやすいです。

*11:ボトムアップドメイン駆動設計 より引用。

*12:https://github.com/nrslib/BottomUpDDDTheLaterPart/blob/c8d6c081041030f2ad2584750c971857686b3bf6/src/Domain/Domain/Users/UserService.cs#L11

*13:class - Was the "interface" keyword removed from Dart? - Stack Overflow

*14:ドメイン層の複数のメソッドを組み合わせて作られているアプリケーションサービスのメソッドを単体テストするのは結合テストとも言えるでしょうか…。

*15:provider v4.0.0 で create に渡した処理の実行が lazy になり、get_it で渡したものを使いたいタイミングよりそのインスタンスの生成が後になってしまいます。対策として lazy を false にしています。

*16:ボトムアップドメイン駆動設計 では「業務のトランザクションを作業の単位として保持するための仕組みです。」、P of EAA: Unit of Work というページでは「Maintains a list of objects affected by a business transaction and coordinates the writing out of changes and the resolution of concurrency problems.」(ビジネストランザクションの影響を受けるオブジェクトのリストを保持し、変更内容の書き込みや並行性の問題解決を取り計らう)とされています。

*17:このアプリではサンプルとして簡潔にするために、初期化済みかどうかを使用時に確認するようにしています。実際には、パブリックなメソッドにしてアプリ起動時の一連の初期化処理内で呼ぶのが良いと思います。

*18:並行処理にしたい場合は、常にトランザクションを使うようにすれば良いかもしれません。

*19:残念ながら Lint のチェックでは引っかからないようです。

*20:List.unmodifiable() では要素の差し替えを防ぐことはできますが、要素として持つオブジェクトの final でない field が変更されるのを防ぐことはできません。DTO は immutable なので安全ですが、そうでないものに対して使う場合にはご注意ください。