ドメイン駆動設計+Nuxt/Vueでリアルタイムバリデーション[発展編]


はじめに

以前の記事、「ドメイン駆動設計 with Vue/Nuxt(Composition API)でリアルタイム・バリデーション」の発展型を作ってみました。

当初VeeValidate3が発表され少し勉強しようと使っていたのですが、2系とは異なる破壊的変更に当初は戸惑いました。いざ自分で似たようなものを作ってみるとinvalidがv-slotで呼ばれている理由など発見も多かったです。
そこで前回の記事を元にして、もう少しVeeValidateに似せたドメイン駆動設計対応のリアルタイムバリデーションを作ってみます。

要件としては

  • ドメインオブジェクトに対する入力情報の可否ロジックの責務はドメインオブジェクトが担う(ここを分離するのが不満だった)
  • リアルタイムバリデーションする
    • エラーは即座に、そのエラーが出ているフォーム付近に表示する
  • 実装があんまり難しくならないようにする
  • アトミックデザインにも対応できるようにする
    • 今回は深く取り組んでいないですが修正すれば可能

コード

GitHubにあげています。
devinoue/realtime-validation-ddd-advanced

ドメイン層の実装

以下は一例で、しかも前回と同じものです。
validationメソッドは必要です。

export default class Name {
  constructor(private _name: string) {
    Name.validation(this._name)
  }

  static validation(name: string): never | void {
    if (name === '') {
      throw new Error('名前を入力してください')
    }
    if (typeof name !== 'string') {
      throw new TypeError('名前は文字列にしてください')
    }
    if (name.length > 8) {
      throw new Error('名前は8文字以内にしてください')
    }
  }
}

子コンポーネントフォーム

使いまわしできるような子コンポーネントinputフォームです。
このフォーム内で設定されるのは、idtypeplaceholderなどHTML寄りの要素です。maxlengthを入れなかったのは、それはドメイン側で操作できたほうが良いかなという理由。

エラーの位置やラベルの位置などはここをいじる、またはそもそもコンポーネントごとに分離するというアトミックデザイン的なやり方もありかもしれません。

<template>
  <div>
    <label :for="labelId">{{ labelName }}</label>
    <input
      :id="labelId"
      v-model="value"
      type="inputType"
      :placeholder="placeHolder"
    />
    <br />
    <span class="error">{{ errorMessage }}</span>
  </div>
</template>
<script lang="ts">
import {
  ref,
  defineComponent,
  watch,
  SetupContext,
  PropType
} from '@vue/composition-api'

import { Domain } from '~/types/index'
export default defineComponent({
  name: 'FormInput',
  props: {
    domainName: {
      type: Function as PropType<Domain>,
      required: true
    },
    labelName: {
      type: String,
      required: true
    },
    labelId: {
      type: String,
      required: true
    },
    inputType: {
      type: String,
      required: true
    },
    placeHolder: {
      type: String,
      required: true
    },
    inputName: {
      type: String,
      required: true
    }
  },
  setup(props, { emit }: SetupContext) {
    const value = ref('')

    const errorMessage = ref('')
    const isValid = ref(false)

    watch(
      value,
      () => {
        isValid.value = false
        errorMessage.value = ''
        emit('input', value.value)
        try {
          props.domainName.validation(value.value)
          isValid.value = true
        } catch (e) {
          errorMessage.value = e.message
        }
      },
      { lazy: true }
    )

    return {
      errorMessage,
      value,
      isValid
    }
  }
})
</script>
<style scoped>
.error {
  color: red;
}
</style>

親コンポーネントの記述

<template>
  <div class="container">
    <ValidationObserver
      ref="observer"
      v-slot="{ isValid }"
      :observer="observer"
    ><!-- 必須定型文 -->
      <FormInput
        v-model="forms.name"
        :domain-name="Name"
        :label-id="'name'"
        :label-name="'名前'"
        :input-type="'text'"
        :input-name="'name'"
        :place-holder="'名前を入力してください'"
      />
      <FormInput
        v-model="forms.email"
        :domain-name="EmailAddress"
        :label-id="'email'"
        :label-name="'メールアドレス'"
        :input-type="'text'"
        :input-name="'email'"
        :place-holder="'メールアドレスを入力してください'"
      />
      <button :disabled="!isValid">ボタン</button>
    </ValidationObserver>
    {{ forms }}
  </div>
</template>
<script lang="ts">
import { ref, reactive } from '@vue/composition-api'
import Name from '~/domain/Name'
import EmailAddress from '~/domain/EmailAddress'
import FormInput from '~/components/ValidationForm/ValidationFormInput.vue'
import ValidationObserver from '~/components/ValidationForm/ValidationObserver.vue'

export default {
  name: 'Index',
  components: { FormInput, ValidationObserver },
  setup() {
    const observer = ref<any>(null) // 必須
    const forms = reactive({
      name: '',
      email: ''
    })

    return { Name, EmailAddress, forms, observer }
  }
}
</script>

上記の方法では、ドメインオブジェクトのうちNameEmailAddressだけ読み込んで、それをリアクティブにして、子コンポーネントの:domainNameディレクティブで渡しています。子コンポーネントは受け取ったドメインオブジェクトを利用してvalidationメソッドを実行しています。

これらの子コンポーネントフォームは、ValidationObserverという名前のカスタムタグで囲まれていますが、これが子コンポーネントのすべてのvalidationに問題がないかをチェックしています。

(このObserverの役割はVeeValidate3と同じですね)

今回はformsで内容を受け取っています。

使用法

index.vueがその例になっていますが改めてご説明させていただきます。
まずバリデーションしたいドメインオブジェクトを作ります。これは普通の.ts/.jsファイルです。バリデーションロジックの書き方はvalidationメソッド内で例外を投げるように書きます。

ほぼ定型文になるのですが、ValidationFormInputコンポーネントを呼び出し、ValidationObserverとその定型文のプロパティでこれを囲みます。
これでValidationFormInputコンポーネントの内容が$ref経由で監視され、OKならisValidtrue、ダメならfalseになります。button要素の:disabledを利用して、ボタンコントロールができます。

また、ValidationFormInputなどはmixinなどで実装するといちいちimportで呼び出さずになります
(今後Nuxtでは自動importが標準で用意されるようなので、mixinも不要になるかもしれませんが)

議論

今回はクラス名をpropsとして渡していますが、これはドメイン駆動設計的にどうなのよ?という疑問がわきます。一つの代案としてはDTO的な何かにオプション情報をまとめて、propsするというものがあるのではないかと思います。それはそれでありなのではないかと思いますが、運用的にはかなり煩雑になるというのもあり、今回は避けました。

終わりに

もう少し改良の余地がありますが、DDDや他のアーキテクチャによるコーディングを実践しながらリアルタイムバリデーションしたいという方の参考になれば幸甚の至り。