😺

Zodをベースとしたドメインモデルと入出力モデルの作成

2024/01/12に公開

はじめに

Zodは、TypeScript用のスキーマ定義とバリデーションのためのライブラリです。
tRPCの入出力のバリデーションでもよく使用されています。
今回、tRPCを使用する際にドメインモデルの作成にZodのスキーマを利用します。
また、レイヤードアーキテクチャ等のPresentation層 ↔︎ UseCase層 ↔︎ Domain層 の各レイヤーでスキーマを派生させていくことで、入出力モデルをより低コストに定義する方法について記載します。

実装について

今回話を進めていくにあたって、一般的なWebサービスにあるUserのような概念を用いていきます。
まず、全体像としてはこちらの図のようになっています。

Domain層に定義されているEntityのスキーマから派生して、UseCaseの入出力になり、さらにそれらがPresentationでさらに加工されAPIなどの入出力になります。
各要素を順を追って詳しく見ていきます。

Domain層

まずベースとなるドメインモデル(Entity)を作成します。
当初下記のようにZodによるスキーマ定義しつつ、そのスキーマから推論される型を実装したクラスベースのEntityを実装していました。

domain/models/userEntity.ts
// Zodによるスキーマ定義
export const userEntitySchema = z
  .object({
    id: iDSchema,
    createdAt: z.date().default(() => new Date()),
    updatedAt: z.date().default(() => new Date()),
    uid: z.string(),
    name: z.string().max(100).nullable(),
    email: z.string().email().nullable().default(null),
    imagePath: z.string().nullish(),
  })

export type UserEntityType = z.infer<typeof userEntitySchema>;

const userEntityCreateSchema = userEntitySchema.omit({
  id: true,
  createdAt: true,
  updatedAt: true,
});
type UserEntityCreateProps = z.input<typeof userEntityCreateSchema>;

// スキーマの型を実装したクラスをEntityとする実装
export class UserEntity implements UserEntityType {
  id: string;
  uid: string;
  name: string | null;
  imagePath?: string | null;
  email: string | null;
  createdAt: Date;
  updatedAt: Date;

  constructor(props: UserEntityType) {
    this.id = props.id;
    this.uid = props.uid;
    this.name = props.name;
    this.imagePath = props.imagePath;
    this.email = props.email;
    this.createdAt = props.createdAt;
    this.updatedAt = props.updatedAt;
  }

  static create(props: UserEntityCreateProps): UserEntity  {
     return new UserEntity(userEntitySchema.parse(props));
  }

  //...下記他のドメインロジック
}

この実装方法だとスキーマ定義とクラス定義を2重に行う必要がありフィールドを追加するたびに両方書き換えが必要になってしまいます。
またclassなのでconstructorも書く必要があり、同じフィールド名が3回も書かれることになり、記述の仕方が冗長に感じます。

ドメインモデルの表現にclassは必要なのか

考える方針として、こちらの記事[1]が参考になりました。classに求めている便利さは下記のようにclassでない手段でも概ね表現できそうです。

  • データに関するメソッド群が集まり、凝集度が高い状態 → そのデータに関する関数を1ファイルにまとめることで代替
  • obj.method()の記法 → moethod(obj)での代替
  • オブジェクトの内部状態の更新 → immutableにすることで代替

これらを採用し、次のようなコードに変更しました。
定義も1度で済み非常にシンプルで良さそうです。

domain/models/userEntity.ts
export const userEntitySchema = z
  .object({
    id: iDSchema,
    createdAt: z.date().default(() => new Date()),
    updatedAt: z.date().default(() => new Date()),
    uid: z.string(),
    name: z.string().max(100).nullable(),
    email: z.string().email().nullable().default(null),
    imagePath: z.string().nullish(),
  })

export type UserEntity = Readonly<z.infer<typeof userEntitySchema>>;

export function createUser(props: UserEntityCreateProps): UserEntity {
  return userEntitySchema.parse(props);
}

//...下記他のドメインロジック

createUser に関しては次のように、名前空間オブジェクトを用いた書き方も可能です。
(私はimport時に別名をつけるのが面倒くさく、あまりこの書き方は使用していません)

export function create(props: UserEntityCreateProps): UserEntity {
  return userEntitySchema.parse(props);
}

// 利用時
import * as UserEntity from '@/domain/models/user/userEntity';
const user = UserEntity.create({ ... });

updateUser などの関数は、userの情報と変更したい値の2つを受け取る必要があるので、正直なところclassベースで書いた時より少し書きにくさはあるなという気がします。

// type
updateUser(user, 更新したい値)
// class
user.update(更新したい値)

余談ですが、欲しいのはOOP風の書き方なので、Goのメソッドのような記法があればなと思ってしまいます。

UseCase層 (Application Service層)

ユーザ作成のUseCaseの入力には、UserEntityの一部の要素を外から受け取る必要があります。
今回は、email はUseCase内で取得するとし、idcreatedAtupdatedAt は入力には不要なので、次のようにUseCaseの入力のスキーマを定義できます。

usecase/user/createUserUseCase.ts
export const createUserUseCaseCommandSchema = userEntitySchema.omit({
  id: true,
  createdAt: true,
  updatedAt: true,
  email: true,
});

UseCaseの出力として、updatedAt は不要だとした場合、次のように出力のスキーマを定義できます。

usecase/user/userDTO.ts
export const userDTOSchema = userEntitySchema
  .omit({
    updatedAt: true,
  })

このようにUseCaseの入出力をomitなどで定義すると userEntity に追加した要素が変更なしに反映されるので、特に入出力の形と集約のルートが近しい場合は便利です。

Presentation層 (tRPCのルーター)

下記コードは、ユーザを作成するMutaionの一部コードです。

presentations/userRouter.ts
router = router({
    create: publicProcedure
      .input(
        createUserUseCaseCommandSchema.omit({
          uid: true,
        }),
      )
      .output(userDTOSchema)
      .mutation(async ({ ctx, input }) => {
        return this.createUserUseCase.execute({
          uid: getUIDFromCtx(ctx), // ctxから何らかの方法でUIDを取得する
          ...input,
        })
      }),

uid はcontextから何かしらの方法で取得するものだとした場合、inputは uid 以外を指定できるようにすれば良いのでこのような定義できます。

createUserUseCaseCommandSchema.omit({
  uid: true,
}),

createUserUseCaseCommandSchemauserEntityScheme をベースとしているので、例えば、userEntitySchema に定義されている name などのバリデーションもそのまま生きています。

name: z.string().max(100).nullable(),

tRPCでは入力を受け取る時に、inputに指定したZodスキーマのバリデーションを自動で行ってくれるので、ドメインモデルに定義しているバリデーションをPresentation層でも2重に定義することなく共通のロジックで実行できます。(バリデーションをかけないようにすることも可能です)

outputには、今回はusecaseの戻り値そのままで良いので userDTOSchmea を指定します。

このように、ドメインモデルのスキーマをベースとすることで、各レイヤーの入出力モデルで必要なプロパティを再定義することなく実装できました。

脚注
  1. GraphQL と Prisma から考える次のN年を見据えた技術選定 ↩︎

BACKSTAGE Tech Blog

Discussion