Validasi Konteks dalam Desain Berbasis Domain

Diterbitkan: 2022-03-11

Desain berbasis domain (singkatnya DDD) bukanlah teknologi atau metodologi. DDD menyediakan struktur praktik dan terminologi untuk membuat keputusan desain yang berfokus dan mempercepat proyek perangkat lunak yang menangani domain yang rumit. Seperti yang dijelaskan oleh Eric Evans dan Martin Fowler, Objek domain adalah tempat untuk meletakkan aturan validasi dan logika bisnis.

Eric Evans:

Lapisan Domain (atau Lapisan Model): Bertanggung jawab untuk mewakili konsep bisnis, informasi tentang situasi bisnis, dan aturan bisnis. Status yang mencerminkan situasi bisnis dikendalikan dan digunakan di sini, meskipun detail teknis penyimpanannya didelegasikan ke infrastruktur. Lapisan ini adalah jantung dari perangkat lunak bisnis.

Martin Fowler:

Logika yang harus ada dalam objek domain adalah logika domain - validasi, kalkulasi, aturan bisnis - apa pun sebutannya.

Validasi Konteks dalam Desain Berbasis Domain

Menempatkan semua validasi di objek Domain menghasilkan objek domain yang besar dan kompleks untuk digunakan. Secara pribadi saya lebih suka ide decoupling validasi domain menjadi komponen validator terpisah yang dapat digunakan kembali kapan saja dan yang akan didasarkan pada konteks dan tindakan pengguna.

Seperti yang ditulis Martin Fowler dalam artikel yang bagus: ContextualValidation.

Satu hal umum yang saya lihat dilakukan orang adalah mengembangkan rutinitas validasi untuk objek. Rutinitas ini datang dalam berbagai cara, mereka mungkin dalam objek atau eksternal, mereka mungkin mengembalikan boolean atau melempar pengecualian untuk menunjukkan kegagalan. Satu hal yang menurut saya terus-menerus membuat orang tersandung adalah ketika mereka memikirkan validitas objek dalam konteks yang independen, seperti yang disiratkan metode isValid. […] Menurut saya jauh lebih berguna untuk menganggap validasi sebagai sesuatu yang terikat pada context , biasanya tindakan yang ingin Anda lakukan. Seperti menanyakan apakah pesanan ini valid untuk diisi, atau apakah pelanggan ini valid untuk check-in ke hotel. Jadi daripada memiliki metode seperti isValid, miliki metode seperti isValidForCheckIn.

Proposal Validasi Tindakan

Pada artikel ini kami akan mengimplementasikan antarmuka ItemValidator sederhana yang Anda perlukan untuk mengimplementasikan metode validasi dengan tipe pengembalian ValidationResult . ValidationResult adalah objek yang berisi item yang telah divalidasi dan juga objek Messages . Yang terakhir berisi akumulasi kesalahan, peringatan, dan status validasi informasi (pesan) tergantung pada konteks eksekusi.

Validator adalah komponen yang dipisahkan yang dapat dengan mudah digunakan kembali di mana pun mereka dibutuhkan. Dengan pendekatan ini semua dependensi, yang diperlukan untuk pemeriksaan validasi, dapat dengan mudah disuntikkan. Misalnya, untuk memeriksa database jika ada pengguna dengan email yang diberikan hanya UserDomainService yang digunakan.

Pemisahan validator akan per konteks (tindakan). Jadi jika tindakan UserCreate dan tindakan UserUpdate akan memisahkan komponen atau tindakan lainnya (UserActivate, UserDelete, AdCampaignLaunch, dll.), validasi dapat berkembang pesat.

Setiap validator tindakan harus memiliki model tindakan yang sesuai yang hanya akan memiliki bidang tindakan yang diizinkan. Untuk membuat pengguna, bidang berikut diperlukan:

ModelBuat Pengguna:

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

Dan untuk memperbarui pengguna, berikut ini diizinkan externalId , firstName dan lastName . externalId digunakan untuk identifikasi pengguna dan hanya perubahan FirstName dan LastName yang diperbolehkan.

Model Pembaruan Pengguna:

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

Validasi integritas bidang dapat dibagikan, panjang maksimum firstName selalu 255 karakter.

Selama memvalidasi, diinginkan untuk tidak hanya mendapatkan kesalahan pertama yang terjadi, tetapi juga daftar semua masalah yang dihadapi. Misalnya, 3 masalah berikut mungkin terjadi secara bersamaan, dan dapat dilaporkan sesuai dengan itu selama eksekusi:

  • format alamat tidak valid [ERROR]
  • email harus unik di antara pengguna [ERROR]
  • kata sandi terlalu pendek [ERROR]

Untuk mencapai validasi semacam itu, sesuatu seperti pembuat status validasi diperlukan, dan untuk tujuan itu Pesan diperkenalkan. Pesan adalah konsep yang saya dengar dari mentor hebat saya beberapa tahun yang lalu ketika dia memperkenalkannya untuk mendukung validasi dan juga untuk berbagai hal lain yang dapat dilakukan dengannya, karena Pesan tidak hanya untuk validasi.

Perhatikan bahwa di bagian berikut kita akan menggunakan Scala untuk mengilustrasikan implementasinya. Jika Anda bukan ahli Scala, jangan takut karena itu harus mudah diikuti.

Pesan dalam Validasi Konteks

Pesan adalah objek yang mewakili pembuat status validasi. Ini menyediakan cara mudah untuk mengumpulkan kesalahan, peringatan, dan pesan informasi selama validasi. Setiap objek Messages memiliki koleksi bagian dalam dari objek Message dan juga dapat memiliki referensi ke objek parentMessages .

Objek Message adalah objek yang dapat memiliki type , messageText , key (yang opsional dan digunakan untuk mendukung validasi input tertentu yang diidentifikasi oleh pengenal), dan terakhir childMessages yang menyediakan cara yang bagus untuk membangun pohon pesan yang dapat dikomposisi.

Pesan dapat berupa salah satu dari jenis berikut:

  • Informasi
  • Peringatan
  • Kesalahan

Pesan terstruktur seperti ini memungkinkan kita untuk membangunnya secara iteratif dan juga memungkinkan keputusan dibuat tentang tindakan selanjutnya berdasarkan status pesan sebelumnya. Misalnya, melakukan validasi selama pembuatan pengguna:

 @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 ) } }

Melihat ke dalam kode ini, Anda dapat melihat penggunaan ValidateUtils. Fungsi utilitas ini digunakan untuk mengisi objek Pesan dalam kasus yang telah ditentukan. Anda dapat melihat implementasi ValidateUtils pada kode Github.

Selama validasi email, pertama-tama diperiksa apakah email valid dengan memanggil ValidateUtils.validateEmail(… , dan juga diperiksa apakah email memiliki panjang yang valid dengan memanggil ValidateUtils.validateLengthIsLessThanOrEqual(… . Setelah kedua validasi ini selesai, periksa apakah email sudah ditetapkan ke beberapa Pengguna dilakukan, hanya jika kondisi validasi email sebelumnya lewat dan itu dilakukan dengan if(!localMessages.hasErrors()) { … . Dengan cara ini, panggilan database yang mahal dapat dihindari. Ini hanya bagian dari UserCreateValidator Kode sumber lengkap dapat ditemukan di sini.

Perhatikan bahwa salah satu parameter validasi menonjol: UserCreateEntity.EMAIL_FORM_ID . Parameter ini menghubungkan status validasi ke ID input tertentu.

Dalam contoh sebelumnya, tindakan selanjutnya diputuskan berdasarkan fakta jika objek Messages memiliki kesalahan (menggunakan metode hasErrors). Seseorang dapat dengan mudah memeriksa apakah ada pesan "PERINGATAN" dan coba lagi jika perlu.

Satu hal yang bisa diperhatikan adalah cara localMessages digunakan. Pesan lokal adalah pesan yang dibuat sama dengan pesan apa pun, tetapi dengan parentMessages. Dengan demikian, tujuannya adalah untuk memiliki referensi hanya ke status validasi saat ini (dalam contoh ini emailValidation), sehingga localMessages.hasErrors dapat dipanggil, di mana ia diperiksa hanya jika konteks emailValidation hasErrors. Juga ketika sebuah pesan ditambahkan ke localMessages, itu juga ditambahkan ke parentMessages sehingga semua pesan validasi ada dalam konteks UserCreateValidation yang lebih tinggi.

Sekarang kita telah melihat Messages beraksi, di bab berikutnya kita akan fokus pada ItemValidator.

ItemValidator - Komponen Validasi yang Dapat Digunakan Kembali

ItemValidator adalah sifat sederhana (antarmuka) yang memaksa pengembang untuk menerapkan metode validasi , yang perlu mengembalikan ValidationResult.

ItemValidator:

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

Hasil Validasi:

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

Ketika ItemValidator seperti UserCreateValidator diimplementasikan menjadi komponen injeksi ketergantungan, maka objek ItemValidator dapat disuntikkan dan digunakan kembali dalam objek apa pun yang memerlukan validasi tindakan UserCreate.

Setelah validasi dijalankan, akan diperiksa apakah validasi berhasil. Jika ya, maka data pengguna disimpan ke database, tetapi jika tidak, respons API yang berisi kesalahan validasi akan dikembalikan.

Di bagian berikutnya, kita akan melihat bagaimana kita dapat menampilkan kesalahan validasi dalam respons RESTful API dan juga cara berkomunikasi dengan konsumen API tentang status tindakan eksekusi.

Respons API Terpadu - Interaksi Pengguna Sederhana

Setelah tindakan pengguna berhasil divalidasi, dalam kasus kami pembuatan pengguna, hasil tindakan validasi perlu ditampilkan kepada konsumen RESTful API. Cara terbaik adalah memiliki respons API terpadu di mana hanya konteks yang akan dialihkan (dalam hal JSON, nilai "data"). Dengan tanggapan terpadu, kesalahan dapat disajikan dengan mudah kepada konsumen RESTful API.

Struktur respons terpadu:

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

Respons terpadu disusun untuk memiliki dua tingkatan pesan, global dan lokal. Pesan lokal adalah pesan yang digabungkan ke input tertentu. Seperti “nama pengguna terlalu panjang, maksimal 80 karakter”_. Pesan global adalah pesan yang mencerminkan keadaan seluruh data di halaman, seperti "pengguna tidak akan aktif sampai disetujui". Pesan lokal dan global memiliki tiga tingkat - kesalahan, peringatan, dan informasi. Nilai "data" khusus untuk konteksnya. Saat membuat pengguna, bidang data akan berisi data pengguna, tetapi saat mendapatkan daftar pengguna, bidang data akan berupa larik pengguna.

Dengan respons terstruktur semacam ini, pengendali UI klien dapat dibuat dengan mudah, yang akan bertanggung jawab untuk menampilkan kesalahan, peringatan, dan pesan informasi. Pesan global akan ditampilkan di bagian atas halaman, karena terkait dengan status tindakan API global, dan pesan lokal dapat ditampilkan di dekat input (bidang) yang ditentukan, karena pesan tersebut terkait langsung dengan nilai bidang. Pesan kesalahan dapat ditampilkan dalam warna merah, pesan peringatan dengan warna kuning, dan informasi dengan warna biru.

Misalnya, dalam aplikasi klien berbasis AngularJS kita dapat memiliki dua arahan yang bertanggung jawab untuk menangani pesan respons lokal dan global, sehingga hanya dua penangan ini yang dapat menangani semua respons secara konsisten.

Arahan untuk pesan lokal perlu diterapkan ke elemen induk ke elemen aktual yang menyimpan semua pesan.

localmessages.directive.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); }); }); } } } } } } })();

Arahan untuk pesan global akan disertakan dalam dokumen tata letak akar (index.html) dan akan didaftarkan ke acara untuk menangani semua pesan global.

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

Untuk contoh yang lebih lengkap, mari kita perhatikan respons berikut yang berisi pesan lokal:

 { "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=" } }

Tanggapan di atas dapat mengarah pada sesuatu sebagai berikut:

Di sisi lain, dengan pesan global sebagai tanggapan:

 { "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]" } }

Aplikasi klien sekarang dapat menampilkan pesan dengan lebih menonjol:

Dalam contoh di atas dapat dilihat bagaimana struktur respons terpadu dapat ditangani untuk setiap permintaan dengan penangan yang sama.

Kesimpulan

Menerapkan validasi pada proyek besar dapat menjadi membingungkan, dan aturan validasi dapat ditemukan di mana-mana di seluruh kode proyek. Menjaga validasi tetap konsisten dan terstruktur dengan baik membuat segalanya lebih mudah dan dapat digunakan kembali.

Anda dapat menemukan ide-ide ini diimplementasikan dalam dua versi boilerplate yang berbeda di bawah ini:

  • Standar Play 2.3, Scala 2.11.1, Slick 2.1, Postgres 9.1, Spring Dependency Injection
  • Reaktif non-blocking Play 2.4, Scala 2.11.7, Slick 3.0, Postgres 9.4, Injeksi Ketergantungan Guice

Dalam artikel ini saya telah mempresentasikan saran saya tentang cara mendukung validasi konteks yang dalam dan dapat dikomposisi yang dapat dengan mudah disajikan kepada pengguna. Saya harap ini akan membantu Anda memecahkan tantangan validasi yang tepat dan penanganan kesalahan untuk selamanya. Silakan tinggalkan komentar Anda dan bagikan pemikiran Anda di bawah ini.