アーキテクチャ上どのレイヤでバリデーションするのか

by

@wapa5pow

DDDやレイヤードアーキテクチャでアプリケーションを書いているとどのレイヤで値のバリデーションをしていくか迷う時があります。
レイヤーが増えたときに多くの場所でバリデーションを書きすぎると重複したコードが増え可読性も生産性も下がります。

レイヤごとに行うバリデーション

それではどのレイヤでどのようなバリデーションをすればいいのでしょうか。以下のようにするといいのではと思っています。

/assets/2023-01-23--which-layer-validates/validation.png

フロントエンド

ユーザの入力はシステムで想定されていない値がくるかもしれないので必要ならバリデーションを行います。
ただフロントエンドでバリデーションは、直接リクエストを変更しサーバにリクエストを投げることにより改変可能なので信用してはいけません。
あくまで最終的なバリデーションはサーバで行うとして多くのリクエストをサーバに投げられるのを防ぐためやサーバにいちいちリクエストしてユーザの利便性を損なうならばバリデーションを入れます。

プレゼンテーション

ユーザの入力を直で受ける部分と、受けた後に呼び出されるビジネスロジックとは明確にバリデーションの方針を変えます。

プレゼンテーション層はどのような値が入力されるかわかりません。その入力された値が正当な値かバリデーションを行う必要があります。このように正常な値が来るはずと決めつけずにプログラミンを書くことを防御的プログラミングと言います。バリデーションを行った後、ビジネスロジックを呼び出します。

ビジネスロジック・データアクセス

防御的プログラミングは安全ですがバリデーションがあちこちに書かれる事になりコードがよみにくくなったり生産性が落ちたりします。

もっと重複なく書く方法はないのでしょうか。そこで契約プログラミングという考え方があります。

契約プログラミングは以下で構成されます。

  • 事前条件: メソッド開始時に保証されるべき条件。メソッドを呼び出す側はこの条件を守る必要がある。例えばキャラクタのHPの初期値をマイナスにしてコンストラクタを呼び出してはいけないなど。
  • 事後条件: メソッド終了時に保証されるべき条件。メソッド実装者がこの条件を守る必要がある。例えばキャラクタがダメージを受けたときのメソッドの処理でHPがマイナスにならないなど。
  • 不変条件: クラスの不変条件。クラスは各種メソッドの呼び出しに対して常にインスタンスの状態が正しい状態でなければならない。例えばキャラクタのHPはどのようなメソッドを呼ばれても不正な値にならないなど。

ビジネスロジック側はクラスや関数を呼び出すとき、かならずプレゼンテーション層を通して呼び出されます。呼び出される引数はバリデーションを終えて信用できるわけなので事前条件が満たされていると仮定します。よってビジネスロジック層以下は契約プログラミングとしてバリデーションを行います。

とは言っても不正な値は入り込むのでは?

クラスをデータベースに保存するとして、クラスの不変条件をしっかり設定しておけばプログラムに不正な値があったとしても不変条件を満たさないのでエラーになるはずです。最終的なデータを守るためにクラスの状態を定義可能なものとしてそれぞれに正しいバリデーションをできると安定したアプリケーションになりそうです。

実例と解説

import 'dart:math';

abstract class Document {
  void validate();
}

final _humans = <Human>[];

class HumanRepository {
  void save(Human human) {
    human.validate();
    _humans.add(human);
    print('save: $human');
  }

  Human find(int id) {
    return _humans.firstWhere((v) => v.id == id);
  }
}

class Human implements Document {
  final id = Random().nextInt(10000);
  final int hp;

  Human(this.hp) {
    validate();
  }

  
  String toString() => 'Human(hp: $hp)';

  
  void validate() {
    if (hp < 0) throw ArgumentError('invalid hp: $hp');
  }

  Human attacked(int damage) {
    assert(damage >= 0);
    final nextHp = max(0, hp - damage);
    return Human(nextHp);
  }
}

class Presentation {
  Human create(String hp) {
    final value = int.parse(hp);
    if (value < 0) {
      throw Exception('hp($hp) should be 0 or positive number');
    }
    return BusinessLogic().create(int.parse(hp));
  }

  Human attack(String id, String damage) {
    final damageValue = int.parse(damage);
    if (damageValue <= 0) {
      throw Exception('damage($damage) should be positive number');
    }
    final idValue = int.parse(id);
    return BusinessLogic().attack(idValue, int.parse(damage));
  }
}

class BusinessLogic {
  final repo = HumanRepository();
  Human create(int hp) {
    final human = Human(hp);
    repo.save(human);
    return human;
  }

  Human attack(int id, int damage) {
    final human = repo.find(id);
    return human.attacked(damage);
  }
}

void main(List<String> arguments) {
  final presentation = Presentation();
  final human = presentation.create(arguments[0]);
  final attackedHuman = presentation.attack(human.id.toString(), arguments[1]);
  print(attackedHuman);
}

プレゼンテーション層では不正な値が入らないのようにバリデーションを行っています。
ビジネスロジック層では契約プログラミングになっているので事前条件は守られているとしてバリデーションを行わずにそのままHumanを呼ぶことができます。
HumanをImmutableにしてコンストラクタでバリデーションをかけることによりクラスの不変条件を守っています。

まとめ

レイヤで行うバリデーションの種類を防御的プログラミングと契約プログラミングでわけることにより不必要なバリデーションを減らせる事がわかりました。
どこでバリデーションを書くかまよったらユーザ入力がある外側だけ防御的プログラミングで守るとよいと思います。

参考