ドメイン駆動設計 with Vue/Nuxt(Composition API)でリアルタイム・バリデーション


追記

2020/05/31 別記事として発展編書きました

問題意識

ドメイン駆動設計に従うVue/Nuxtアプリケーションを作っていて、
名前フォームに文字列を打ち込んで8文字より多く書かれたとき、
そのフォームの一つ上に赤い文字で「名前は8文字以内です」というエラーをリアルタイムで表示してほしい、
……という要件があるとしましょう。

このときVue側のコードで、インプットフォームから受け取った変数に8文字という制限をすることも可能なのですが、
「それって 利口なUI という奴なのでは?」と考えていました。
一般に利口なUIはアンチパターンとして知られていて、(議論はありますが)たしかに、ドメイン知識がUI層に流出しています。
どのみちエンジニア的にも同じ内容を2度書くような気がして、利口なUIは利口じゃないコードになりかねません。

そういうわけで自分なりにその解決をしてみたいと思います。

完成品

devinoue/realtime-validation-ddd

環境

Nuxt2.12.2
Composition API
TypeScript

ドメインを書く

今回はNameクラスをTypeScriptで書きますが、必要そうな所だけです。
ひとまずdomainというディレクトリに以下のような値オブジェクトとしてName.tsを入れておきます。

domain/Name.ts
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文字以内にしてください')
    }
  }
}

ついでに、Eメールアドレス用値オブジェクトも作っておきます。
コード的にはほぼ同じになってしまうので、こちらから御覧ください。

ハンドラを書く

Composition APIで作るので、ハンドラも切り分けておきます。
別にそういうvue界の慣習があるわけではありませんし、切り分けなくてもいいのですが、
この方が見晴らしがいいという理由で分けています。
ちなみにDDDでいうプレゼンテーション層に属するものとして扱っています。
ここからドメイン知識にアクセスしていますが、生成メソッドではないため許容されるという認識です。

handler/InputHandler.ts
import { ref, watch } from '@vue/composition-api'
import Name from '~/domain/Name'
import EmailAddress from '~/domain/EmailAddress'

interface IForm {
  name: string
  email: string
}

export default function() {
  const defaultInput: IForm = { name: '', email: '' }

  const forms = reactive({ ...defaultInput })
  const errors = reactive({ ...defaultInput })

  watch(
    () => forms.name,
    () => {
      errors.name = ''
      try {
        Name.validation(forms.name)
      } catch (e) {
        errors.name = e.message
      }
    },
    { lazy: true }
  )
  watch(
    () => forms.email,
    () => {
      errors.email = ''
      try {
        EmailAddress.validation(forms.email)
      } catch (e) {
        errors.email = e.message
      }
    },
    { lazy: true }
  )

  return { forms, errors }
}


ここ、無駄が多い気がしますが、watch関数を使っている手前まとめにくい、、、、

vueファイルを完成させる

さて、残りはpages以下のindex.vueで、さきほど作ったファイルを読み込むだけです。

pages/index.vue
<template>
  <div class="container">
    <span v-show="errors.name" class="error">{{ errors.name }}</span>
    <span>名前 : <input v-model="name" type="text"/></span>
    <br />
    <span v-show="errors.email" class="error">{{ errors.email }}</span>
    <span>メールアドレス : <input v-model="email" type="text"/></span>
    <br />
  </div>
</template>

<script lang="ts">
import useInputHandler from '~/handler/InputHandler'
export default {
  setup() {
    return { ...useInputHandler() }
  }
}
</script>
// スタイル省略

御覧ください!! ほとんど空っぽ! なんとスッキリしているのでしょう!!!😭
今までのVueファイルがウソのようにキレイにまとめられました!

動作イメージ

何か書き込むたびにwatchメソッドが変数を監視して、エラーを報告してくれます。
これで「利口なUI」とならずに済みます😄

終わりに

Vue3素晴らしい……!

GitHub:
devinoue/realtime-validation-ddd