ドメイン駆動設計におけるコンテキスト検証

公開: 2022-03-11

ドメイン駆動設計(略してDDD)は、テクノロジーや方法論ではありません。 DDDは、複雑なドメインを扱うソフトウェアプロジェクトに焦点を当てて加速する設計上の決定を行うための実践と用語の構造を提供します。 EricEvansとMartinFowlerが説明したように、ドメインオブジェクトは、検証ルールとビジネスロジックを配置する場所です。

エリック・エバンス:

ドメイン層(またはモデル層):ビジネスの概念、ビジネス状況に関する情報、およびビジネスルールを表す責任があります。 保管の技術的な詳細はインフラストラクチャに委任されていますが、ここではビジネスの状況を反映する状態が制御および使用されます。 この層はビジネスソフトウェアの心臓部です。

マーティンファウラー:

ドメインオブジェクトに含まれるべきロジックは、ドメインロジック(検証、計算、ビジネスルール)です。

ドメイン駆動設計におけるコンテキスト検証

すべての検証をドメインオブジェクトに入れると、巨大で複雑なドメインオブジェクトを操作できるようになります。 個人的には、ドメイン検証を、いつでも再利用でき、コンテキストとユーザーアクションに基づく個別のバリデーターコンポーネントに分離するというアイデアを非常に好みます。

マーティンファウラーが素晴らしい記事で書いたように:ContextualValidation。

私がよく目にすることの1つは、オブジェクトの検証ルーチンを開発することです。 これらのルーチンにはさまざまな方法があり、オブジェクト内または外部にある場合があり、ブール値を返すか、失敗を示すために例外をスローする場合があります。 私が常に人々をつまずかせていると思うことの1つは、isValidメソッドが意味するように、コンテキストに依存しない方法でオブジェクトの妥当性を考えるときです。 […]検証をコンテキストにバインドされたもの、通常は実行したいアクションと考える方がはるかに便利だと思います。 この注文に応じるのが有効かどうか、またはこの顧客がホテルにチェックインするのに有効かどうかを尋ねるなど。 したがって、isValidのようなメソッドではなく、isValidForCheckInのようなメソッドを使用します。

アクション検証の提案

この記事では、戻り型ValidationResultvalidateメソッドを実装する必要がある単純なインターフェースItemValidatorを実装します。 ValidationResultは、検証されたアイテムとメッセージオブジェクトを含むオブジェクトです。 後者には、実行コンテキストに応じたエラー、警告、および情報検証状態(メッセージ)の累積が含まれます。

バリデーターは分離されたコンポーネントであり、必要な場所で簡単に再利用できます。 このアプローチでは、検証チェックに必要なすべての依存関係を簡単に注入できます。 たとえば、特定の電子メールを持つユーザーがデータベースに存在するかどうかをチェックインするには、UserDomainServiceのみが使用されます。

バリデーターのデカップリングは、コンテキスト(アクション)ごとに行われます。 したがって、UserCreateアクションとUserUpdateアクションにコンポーネントまたはその他のアクション(UserActivate、UserDelete、AdCampaignLaunchなど)が分離されている場合、検証は急速に拡大する可能性があります。

各アクションバリデーターには、許可されたアクションフィールドのみを持つ対応するアクションモデルが必要です。 ユーザーを作成するには、次のフィールドが必要です。

UserCreateModel:

 { "firstName": "John", "lastName": "Doe", "email": "[email protected]", "password": "MTIzNDU=" }

また、ユーザーを更新するために、 externalIdfirstNamelastNameが許可されています。 externalIdはユーザーの識別に使用され、 firstNamelastNameの変更のみが許可されます。

UserUpdateModel:

 { "externalId": "a55ccd60-9d82-11e5-9f52-0002a5d5c51b", "firstName": "John Updated", "lastName": "Doe Updated" }

フィールド整合性の検証は共有できます。firstNameの最大長は常に255文字です。

検証中に、発生した最初のエラーだけでなく、発生したすべての問題のリストを取得することが望ましいです。 たとえば、次の3つの問題が同時に発生する可能性があり、実行中にそれに応じて報告できます。

  • 無効なアドレス形式[エラー]
  • メールはユーザー間で一意である必要があります[エラー]
  • パスワードが短すぎます[エラー]

この種の検証を実現するには、検証状態ビルダーのようなものが必要であり、そのためにメッセージが導入されます。 メッセージは、検証をサポートするために、またメッセージは検証のためだけのものではないため、それを使用して実行できる他のさまざまなことのために、彼がそれを導入したときに私の偉大なメンターから聞いた概念です。

次のセクションでは、Scalaを使用して実装を説明することに注意してください。 あなたがScalaの専門家でない場合に備えて、それでも簡単にフォローできるはずなので、恐れることはありません。

コンテキスト検証のメッセージ

メッセージは、検証状態ビルダーを表すオブジェクトです。 検証中にエラー、警告、および情報メッセージを収集する簡単な方法を提供します。 各Messagesオブジェクトには、 Messageオブジェクトの内部コレクションがあり、 parentMessagesオブジェクトへの参照を持つこともできます。

メッセージオブジェクトは、タイプmessageTextkey (オプションであり、識別子によって識別される特定の入力の検証をサポートするために使用されます)、そして最後に、構成可能なメッセージツリーを構築するための優れた方法を提供するchildMessagesを持つことができるオブジェクトです。

メッセージは、次のいずれかのタイプになります。

  • 情報
  • 警告
  • エラー

このように構造化されたメッセージを使用すると、メッセージを繰り返し作成でき、前のメッセージの状態に基づいて次のアクションについて決定を下すことができます。 たとえば、ユーザーの作成中に検証を実行します。

 @Component class UserCreateValidator @Autowired (private val entityDomainService: UserDomainService) extends ItemValidator[UserCreateEntity] { Asserts.argumentIsNotNull(entityDomainService) private val MAX_ALLOWED_LENGTH = 80 private val MAX_ALLOWED_CHARACTER_ERROR = s"must be less than or equal to $MAX_ALLOWED_LENGTH character" override def validate(item: UserCreateEntity): ValidationResult[UserCreateEntity] = { Asserts.argumentIsNotNull(item) val validationMessages = Messages.of validateFirstName (item, validationMessages) validateLastName (item, validationMessages) validateEmail (item, validationMessages) validateUserName (item, validationMessages) validatePassword (item, validationMessages) ValidationResult( validatedItem = item, messages = validationMessages ) } private def validateFirstName(item: UserCreateEntity, validationMessages: Messages) { val localMessages = Messages.of(validationMessages) val fieldValue = item.firstName ValidateUtils.validateLengthIsLessThanOrEqual( fieldValue, MAX_ALLOWED_LENGTH, localMessages, UserCreateEntity.FIRST_NAME_FORM_ID.value, MAX_ALLOWED_CHARACTER_ERROR ) } private def validateLastName(item: UserCreateEntity, validationMessages: Messages) { val localMessages = Messages.of(validationMessages) val fieldValue = item.lastName ValidateUtils.validateLengthIsLessThanOrEqual( fieldValue, MAX_ALLOWED_LENGTH, localMessages, UserCreateEntity.LAST_NAME_FORM_ID.value, MAX_ALLOWED_CHARACTER_ERROR ) } private def validateEmail(item: UserCreateEntity, validationMessages: Messages) { val localMessages = Messages.of(validationMessages) val fieldValue = item.email ValidateUtils.validateEmail( fieldValue, UserCreateEntity.EMAIL_FORM_ID, localMessages ) ValidateUtils.validateLengthIsLessThanOrEqual( fieldValue, MAX_ALLOWED_LENGTH, localMessages, UserCreateEntity.EMAIL_FORM_ID.value, MAX_ALLOWED_CHARACTER_ERROR ) if(!localMessages.hasErrors()) { val doesExistWithEmail = this.entityDomainService.doesExistByByEmail(fieldValue) ValidateUtils.isFalse( doesExistWithEmail, localMessages, UserCreateEntity.EMAIL_FORM_ID.value, "User already exists with this email" ) } } private def validateUserName(item: UserCreateEntity, validationMessages: Messages) { val localMessages = Messages.of(validationMessages) val fieldValue = item.username ValidateUtils.validateLengthIsLessThanOrEqual( fieldValue, MAX_ALLOWED_LENGTH, localMessages, UserCreateEntity.USERNAME_FORM_ID.value, MAX_ALLOWED_CHARACTER_ERROR ) if(!localMessages.hasErrors()) { val doesExistWithUsername = this.entityDomainService.doesExistByUsername(fieldValue) ValidateUtils.isFalse( doesExistWithUsername, localMessages, UserCreateEntity.USERNAME_FORM_ID.value, "User already exists with this username" ) } } private def validatePassword(item: UserCreateEntity, validationMessages: Messages) { val localMessages = Messages.of(validationMessages) val fieldValue = item.password ValidateUtils.validateLengthIsLessThanOrEqual( fieldValue, MAX_ALLOWED_LENGTH, localMessages, UserCreateEntity.PASSWORD_FORM_ID.value, MAX_ALLOWED_CHARACTER_ERROR ) } }

このコードを見ると、ValidateUtilsの使用法がわかります。 これらのユーティリティ関数は、事前定義された場合にメッセージオブジェクトにデータを入力するために使用されます。 GithubコードでのValidateUtilsの実装を確認できます。

電子メールの検証中に、最初にValidateUtils.validateEmail(…を呼び出すことによって電子メールが有効かどうかがチェックされます。また、ValidateUtils.validateLengthIsLessThanOrEqual(…を呼び出すことによって電子メールが有効な長さであるかどうかもチェックされます。以前の電子メール検証条件が通過し、 if(!localMessages.hasErrors()){…で行われる場合にのみ、一部のユーザーにすでに割り当てられています。このようにして、高価なデータベース呼び出しを回避できます。これはUserCreateValidatorの一部にすぎません。 。完全なソースコードはここにあります。

検証パラメーターの1つであるUserCreateEntity.EMAIL_FORM_IDが目立つことに注意してください。 このパラメーターは、検証状態を特定の入力IDに接続します。

前の例では、 Messagesオブジェクトにエラーがあるかどうかに基づいて次のアクションが決定されます(hasErrorsメソッドを使用)。 「警告」メッセージがあるかどうかを簡単に確認し、必要に応じて再試行できます。

気付くことができることの1つは、 localMessagesの使用方法です。 ローカルメッセージは、他のメッセージと同じように作成されたメッセージですが、parentMessagesを使用します。 そうは言っても、目標は現在の検証状態(この例ではemailValidation)のみを参照することであるため、 localMessages.hasErrorsを呼び出すことができ、emailValidationコンテキストにhasErrorsがある場合にのみチェックされます。 また、メッセージがlocalMessagesに追加されると、それはparentMessagesにも追加されるため、すべての検証メッセージはUserCreateValidationの上位コンテキストに存在します。

メッセージの動作を確認したので、次の章ではItemValidatorに焦点を当てます。

ItemValidator-再利用可能な検証コンポーネント

ItemValidatorは、開発者にValidationResultを返す必要のあるメソッドvalidateの実装を強制する単純な特性(インターフェース)です。

ItemValidator:

 trait ItemValidator[T] { def validate(item:T) : ValidationResult[T] }

ValidationResult:

 case class ValidationResult[T: Writes]( validatedItem : T, messages : Messages ) { Asserts.argumentIsNotNull(validatedItem) Asserts.argumentIsNotNull(messages) def isValid :Boolean = { !messages.hasErrors } def errorsRestResponse = { Asserts.argumentIsTrue(!this.isValid) ResponseTools.of( data = Some(this.validatedItem), messages = Some(messages) ) } }

UserCreateValidatorなどのItemValidatorが依存性注入コンポーネントとして実装されている場合、ItemValidatorオブジェクトを注入して、UserCreateアクションの検証が必要な任意のオブジェクトで再利用できます。

検証が実行された後、検証が成功したかどうかがチェックされます。 そうである場合、ユーザーデータはデータベースに保持されますが、そうでない場合は、検証エラーを含むAPI応答が返されます。

次のセクションでは、RESTful API応答で検証エラーを表示する方法と、実行アクションの状態についてAPIコンシューマーと通信する方法について説明します。

統一されたAPI応答-シンプルなユーザーインタラクション

ユーザーアクションが正常に検証された後、この場合はユーザー作成で、検証アクションの結果をRESTfulAPIコンシューマーに表示する必要があります。 最善の方法は、コンテキストのみが切り替えられる統一されたAPI応答を用意することです(JSONに関しては、「データ」の値)。 統一された応答により、RESTfulAPIコンシューマーにエラーを簡単に提示できます。

統一された応答構造:

 { "messages" : { "global" : { "info": [], "warnings": [], "errors": [] }, "local" : [] }, "data":{} }

統合応答は、グローバルとローカルの2層のメッセージを持つように構成されています。 ローカルメッセージは、特定の入力に結合されたメッセージです。 「ユーザー名が長すぎます。最大80文字まで使用できます」など。 グローバルメッセージは、「ユーザーは承認されるまでアクティブになりません」など、ページ上のデータ全体の状態を反映するメッセージです。 ローカルメッセージとグローバルメッセージには、エラー、警告、情報の3つのレベルがあります。 「データ」の値はコンテキストに固有です。 ユーザーを作成する場合、データフィールドにはユーザーデータが含まれますが、ユーザーのリストを取得する場合、データフィールドはユーザーの配列になります。

この種の構造化された応答を使用すると、クライアントUIハンドラーを簡単に作成でき、エラー、警告、および情報メッセージの表示を担当します。 グローバルメッセージはグローバルAPIアクションの状態に関連しているため、ページの上部に表示されます。ローカルメッセージは、フィールドの値に直接関連しているため、指定した入力(フィールド)の近くに表示できます。 エラーメッセージは赤色で、警告メッセージは黄色で、情報は青色で表示できます。

たとえば、AngularJSベースのクライアントアプリでは、ローカルおよびグローバルの応答メッセージの処理を担当する2つのディレクティブを使用できるため、これら2つのハンドラーのみが一貫した方法ですべての応答を処理できます。

ローカルメッセージのディレクティブは、すべてのメッセージを保持している実際の要素の親要素に適用する必要があります。

localmessages.direcitive.js

 (function() { 'use strict'; angular .module('reactiveClient') .directive('localMessagesValidationDirective', localMessagesValidationDirective); /** @ngInject */ function localMessagesValidationDirective(_) { return { restrict: 'AE', transclude: true, scope: { binder: '=' }, template: '<div ng-transclude></div>', link: function (scope, element) { var messagesWatchCleanUp = scope.$watch('binder', messagesBinderWatchCallback); scope.$on('$destroy', function() { messagesWatchCleanUp(); }); function messagesBinderWatchCallback (messagesResponse) { if (messagesResponse != undefined && messagesResponse.messages != undefined) { if (messagesResponse.messages.local.length > 0) { element.find('.alert').remove(); _.forEach(messagesResponse.messages.local, function (localMsg) { var selector = element.find('[]').parent(); _.forEach(localMsg.info, function (msg) { var infoMsg = '<div class="form-control validation-alert alert alert-info alert-dismissable"><button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>' + msg + '</div>'; selector.after(infoMsg); }); _.forEach(localMsg.warnings, function (msg) { var warningMsg = '<div class="form-control validation-alert alert alert-warning alert-dismissable"><button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>' + msg + '</div>'; selector.after(warningMsg); }); _.forEach(localMsg.errors, function (msg) { var errorMsg = '<div class="form-control validation-alert alert alert-danger alert-dismissable"><button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>' + msg + '</div>'; selector.after(errorMsg); }); }); } } } } } } })();

グローバルメッセージのディレクティブはルートレイアウトドキュメント(index.html)に含まれ、すべてのグローバルメッセージを処理するためのイベントに登録されます。

globalmessages.directive.js

 (function() { 'use strict'; angular .module('reactiveClient') .directive('globalMessagesValidationDirective', globalMessagesValidationDirective); /** @ngInject */ function globalMessagesValidationDirective(_, toastr, $rootScope, $log) { return { restrict: 'AE', link: function (scope) { var cleanUpListener = $rootScope.$on('globalMessages', globalMessagesWatchCallback); scope.$on('$destroy', function() { cleanUpListener(); }); function globalMessagesWatchCallback (event, messagesResponse) { $log.log('received rootScope event: ' + event); if (messagesResponse != undefined && messagesResponse.messages != undefined) { if (messagesResponse.messages.global != undefined) { _.forEach(messagesResponse.messages.global.info, function (msg) { toastr.info(msg); }); _.forEach(messagesResponse.messages.global.warnings, function (msg) { toastr.warning(msg); }); _.forEach(messagesResponse.messages.global.errors, function (msg) { toastr.error(msg); }); } } } } } } })();

より完全な例として、ローカルメッセージを含む次の応答について考えてみましょう。

 { "messages" : { "global" : { "info": [], "warnings": [], "errors": [] }, "local" : [ { "inputId" : "email", "errors" : ["User already exists with this email"], "warnings" : [], "info" : [] } ] }, "data":{ "firstName": "John", "lastName": "Doe", "email": "[email protected]", "password": "MTIzNDU=" } }

上記の応答は、次のようなものにつながる可能性があります。

一方、応答としてグローバルメッセージを使用する場合:

 { "messages" : { "global" : { "info": ["User successfully created."], "warnings": ["User will not be available for login until is activated"], "errors": [] }, "local" : [] }, "data":{ "externalId": "a55ccd60-9d82-11e5-9f52-0002a5d5c51b", "firstName": "John", "lastName": "Doe", "email": "[email protected]" } }

クライアントアプリは、メッセージをより目立つように表示できるようになりました。

上記の例では、同じハンドラーを使用して、任意のリクエストに対して統一された応答構造を処理する方法を確認できます。

結論

大規模なプロジェクトに検証を適用すると混乱する可能性があり、検証ルールはプロジェクトコード全体のいたるところにあります。 検証の一貫性と構造を維持することで、物事がより簡単に再利用できるようになります。

これらのアイデアは、以下の2つの異なるバージョンの定型文に実装されています。

  • Standard Play 2.3、Scala 2.11.1、Slick 2.1、Postgres 9.1、Spring Dependency Injection
  • リアクティブノンブロッキングPlay2.4、Scala 2.11.7、Slick 3.0、Postgres 9.4、Guice依存性注入

この記事では、ユーザーに簡単に提示できる、深く構成可能なコンテキスト検証をサポートする方法についての提案を示しました。 これが、適切な検証とエラー処理の課題を完全に解決するのに役立つことを願っています。 コメントを残して、以下にあなたの考えを共有してください。