わるいドメインモデル

1. あまりよくないドメインモデル


@freezed
class User with _$User {
  const factory User({
    required String userName, // ユーザーネーム
    required String userId, // ユーザーID
    required DateTime dateOfBirth, // 生年月日
    required bool isVerified, // 2段階認証フラグ
    String? phoneNumber, // 電話番号
  }) = _User;
}

何がまずいのだろうか?

いくつかあるが、第一にこのモデルは、属性の定義しかされておらず、ビジネス上の振る舞いやルールを読み取ることが出来ず、事実上ただの情報の入れ物と化している点にある。これを「ドメイン貧血症(Anemic domain model)」と呼ぶ。

例えば、以下のようなコードの問題点はどこだろうか?

final class PoorUserUseCase {
  final UserRepositoy _repository;

  PoorUserUseCase(this.repository);

  Future<void> createUser({
    required String userId,
    required String userName,
  }) async {
    final user = User(
      id: userId,
      name: userName,
      dateOfBirth: DateTime(1890, 1, 1),
      isVerified: true,
    );
    
    await _repository.createUser(user);
  }
}

問題点は2つ、生年月日(dateOfBirth)と2段階認証フラグ(isVerified)の値である。

まず、入力情報が正しければこの御仁はどうやら130歳を超えるかなりの尊老だが、現代的ビジネスの価値観だとまずターゲットユーザーにはならないだろう。少なくとも今は。

そして、倫理的、法的な制約から成人(20歳)以下、もしくは高校生(16歳)以下の登録も弾きたいかもしれない。

次に、2段階認証フラグの値が最初からtrueになっている点だ。大抵のサービスでは、まず先にユーザーの登録を行ってから2段階認証のフェーズへと移るはずだが、このコードだと最初から認証済みの状態でユーザードメインが生成されている。

この様にクラスを生成する際のビジネス的制約が定義されていないため、不整合なデータがどこからでも作り出せてしまう点にある。

このコードだと生年月日などが直接記述されているからまだわかりやすいが、大抵はPresentation層で入力された値が引数で渡されてくるので、見た目上だと正しい実装になってしまっており、不整合な処理が行われていることに気がつけないことが多い。

そして、最悪の場合DBに登録された後に、不正なデータの存在が発覚して、どの時点で作成されたのかを特定するためにちゃぶ台をひっくり返す必要が出てくるかもしれない。

2. ならバリデーションチェックをすればいいじゃない


もちろん通常はバリデーションチェックを行うはずである。

では、どこで?第一に思いつくのがApplication層のUseCaseやService、次にPresentation層のViewやViewModelなどである。

final class PoorUserUseCase {
  final UserRepositoy _repository;

  PoorUserUseCase(this.repository);

  Future<void> createUser({
    required String userId,
    required String userName,
    required DateTime dateOfBirth,
  }) async {
    if (!isValidAge(birthday)) return;
    final user = User(
      id: userId,
      name: userName,
      dateOfBirth: DateTime(1890, 1, 1),
      isVerified: true,
    );

    await _repository.createUser(user);
  }

  bool _isValidAge(DateTime dateOfBirth) {
    final now = DateTime.now();

    int age = now.year - dateOfBirth.year;
    if (now.month < dateOfBirth.month ||
        (now.month == dateOfBirth.month && now.day < dateOfBirth.day)) {
      age--;
    }

    return age >= 16 && age <= 120;
  }
}

だが、これも将来的に大きな問題になりうる。

このように本来ドメインロジックとして扱われるべきルールや振る舞いがアプリケーション層やプレゼンテーション層など、複数の場所に分散して書かれていることを「ドメイン知識の流出」と表現したりする。

ではそれの何が問題なのだろうか?

例えばサービスが成長するにつれて、UseCaseやServiceが大量に増えていったとする。
バリデーション処理を一々UseCaseなどに書いていると、後からUserの正しい仕様と追いかけようとすると大量のUseCaseやService(大規模プロジェクトだとそれこそ500個ぐらいあるらしい)からコード参照する羽目になる。

さらに、医療技術の進歩により人間の寿命が300歳ぐらいになったら、当然上述した130歳の御仁は尊老でもなんでもなくなり、真っ当なビジネスターゲットになるかもしれない。

以前とは大幅に社会的状況が変わっていく中、あなたは新たな要件で250歳まで登録できるように実装しました。

そして、実装した後に気付く、「以前は130歳が上限じゃなかったっけ?」

なんなら、前任者たちは寿命が伸びるにつれて都度上限や下限がバラバラな実装を行ってきたらしい。
こうしてあらゆる年齢で登録できたりできなかったりするアプリが生まれましたとさ。どうしてこうなった。

3. ドメインロジックはドメインモデルに書こう


「何歳以上何歳未満が登録できない」という知識は、本来はそのドメインのビジネス上での振る舞いや制約に属する知識のはずだ。

ここで最初の問題にもどる。はじめに見たUserドメインモデルの問題は、ドメインモデルからはそれらのルールを読み取れないことにあったはず。ならドメインロジックをドメインモデルに書いたらどうだろう?

@freezed
class User with _$User {
  const User._();
  
  const factory User._internal({ // プライベートコンストラクタ化しておく
    @protected required String userName, // ユーザーネーム
    @protected required String userId, // ユーザーID
    @protected required DateTime dateOfBirth, // 生年月日
    @protected required bool isVerified, // 2段階認証フラグ
    @protected String? phoneNumber, // 電話番号
  }) = _User;
  
  factory User.create({
    required String userName,
    required String userId,
    required DateTime dateOfBirth,
    required bool isVerified,
    String? phoneNumber,
  }) {
    final now = DateTime.now();
    int age = now.year - dateOfBirth.year;
    if (now.month < dateOfBirth.month ||
        (now.month == dateOfBirth.month && now.day < dateOfBirth.day)) {
      age--;
    }
    if (age < 16 || age > 130) {
      throw ArgumentError('16歳以上130歳以下のみ登録できます');
    }
    
    return User._internal(
      userName: userName,
      userId: userId,
      dateOfBirth: dateOfBirth,
      isVerified: isVerified,
      phoneNumber: phoneNumber,
    );
  }
}

コンストラクターをプライベート化することで、外部からの不正なドメイン生成を防ぎつつ、バリデーション付きのインスタンス生成ファクトリからのみインスタンスを生成できるようにすることで、ドメインを見るだけで仕様を理解できるし、どのUseCaseやServiceでドメインを生成しても常に有効な値を持つことが保証されるようになった。仕様を変更したい時もドメインのコードを変更するだけで良い。めでたしめでたし。

4. ドメインモデルに書くべきではないルール


さて、ここで追加実装が必要になりました。既に登録されている電話番号ではUserの登録が出来ないようにしたいらしい。電話番号の一意性は当然他のUserの電話番号を参照することでしか確認できない。

ではどうする?簡単だ。ファクトリに新しくバリデーションを実装しよう。

factory User.create({
    required String userName,
    required String userId,
    required DateTime dateOfBirth,
    required bool isVerified,
    String? phoneNumber,
    required List<User> existingUsers, // ← 他ユーザー配列を引数で渡す
  }) {
    // 重複チェック(電話番号が一致するユーザーがいればエラー)
    if (phoneNumber != null &&
        existingUsers.any((user) => user.phoneNumber == phoneNumber)) {
      throw ArgumentError('この電話番号は既に登録されています');
    } 
    
    final now = DateTime.now();
    int age = now.year - dateOfBirth.year;
    if (now.month < dateOfBirth.month ||
        (now.month == dateOfBirth.month && now.day < dateOfBirth.day)) {
      age--;
    }
    if (age < 16 || age > 130) {
      throw ArgumentError('16歳以上130歳以下のみ登録できます');
    }
    
    return User._internal(
      userName: userName,
      userId: userId,
      dateOfBirth: dateOfBirth,
      isVerified: isVerified,
      phoneNumber: phoneNumber,
    );
  }

だがそうは問屋が卸さない。

上記のコードはある重大な禁忌を犯している。

それは、「ドメインモデルが外部のコンテキスト(他のユーザーの情報)を知ってしまっている」という点だ。

まず、他のユーザーの情報という外部情報に頼ってしまうと、そのオブジェクト単体で矛盾なく存在できるという自己完結性が失われてしまう。

そうなると、単体テストもより難しくなってしまうし、この一意性というのもコンテキストによって変わってくるからだ。

では、重複チェックのビジネスロジックはどこに実装すればいい?色んなパターンが考えられるが一番多いのがDomain Serviceに書くというものだ。

final class UserDomainService {
  static bool isDuplicatedPhoneNumber({
    required String phoneNumber,
    required List<User> users,
  }) {
    final phoneNumbers = users
        .map((user) => user.phoneNumber)
        .whereType<String>()
        .toSet();

    return phoneNumbers.contains(phoneNumber);
  }
}
final existingUsers = [
  // ...リポジトリなどから取得した既存ユーザーのリスト
];

final newPhoneNumber = '09012345678';

if (UserDomainService.isDuplicatedPhoneNumber(
  phoneNumber: newPhoneNumber,
  users: existingUsers,
)) {
  throw Exception('この電話番号は既に登録されています');
}

final user = User.create(
  userName: 'Kazushi',
  userId: 'abc1234567',
  dateOfBirth: DateTime(2000, 5, 16),
  isVerified: false,
  phoneNumber: newPhoneNumber,
);

こうすることで、ドメインの純粋性を守るとともに、ドメイン知識の流出も抑えられる。
注意点としては、Domain Serviceは状態を持たない純粋なロジックでかくこと。

5. ドメインプリミティブで書こう


ユーザーの名前や生年月日など、ビジネスドメインで意味や制約を持つ値は専用のValueObjectで表現するほうがより堅牢な設計になる。

例えば、上記のUserドメインだと以下の様に書く。

@freezed
class User with _$User {
  const User._();
  
  const factory User._internal({ // プライベートコンストラクタ化しておく
    required UserName userName, // ユーザーネーム
    required UserId userId, // ユーザーID
    required DateOfBirth dateOfBirth, // 生年月日
    required bool isVerified, // 2段階認証フラグ
    PhoneNumber? phoneNumber, // 電話番号
  }) = _User;
  
  // ...
@immutable
final class DateOfBirth {
  final DateTime value;

  DateOfBirth(this.value) {
  final now = DateTime.now();
    int age = now.year - dateOfBirth.year;
    if (now.month < dateOfBirth.month ||
        (now.month == dateOfBirth.month && now.day < dateOfBirth.day)) {
      age--;
    }
    if (age < 16 || age > 130) {
      throw ArgumentError('16歳以上130歳以下のみ登録できます');
    }
  }

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is OwnedAt &&
          runtimeType == other.runtimeType &&
          value == other.value;

  @override
  int get hashCode => value.hashCode;
}

ドメインプリミティブに書くことで

  • バリデーションを型内で担保できる
  • 意味や制約を型で表現できるためため可読性が上がる
  • ドメインの流出も抑えられやすい
  • 同じ型の別の値を取り違える危険性が減る
  • 再利用性が高い

といったメリットが享受できる

まとめ


  • ドメインモデルにはビジネス上の振る舞いや制約を書く
  • 意味のある値はドメインの値でラップする

実を言うと以上のコードだとドメイン貧血症を防げているとは言い難いがそこはまたいつかやります