フルスタックなTypeScript環境 (Blitz.js) でDDDする

16 min read読了の目安(約15200字

はじめまして。
株式会社digsasでCPOを務めるmorika2と申します。

当社は「変遷するビジネスに、IT投資のモノサシを」作る、というミッションを元に、IT投資におけるユーザー企業の導入設計力を向上させるためのプロダクトを開発しています。

Tech系の情報発信自体もzennに投稿するのもルーキーですが、表題のような構成をあまり見かけないので、静的型付けブームに則ってどなたかの参考になればと思い筆を取りました。

以前noteに書いてみたのがこちら

筆者プロフィール

  • DTMer
  • ネトゲ廃人
  • フリーでWEBマーケ&WEBデザインの提案・実務
  • UI/UX設計 → JSドハマリ
  • システム開発会社立ち上げ
  • 企業研修・スクール講師(Markup/PS/AI/Figma/jQuery/Webpackあたり)
  • JSゲームエンジン開発(趣味)
  • 開発会社Exit
  • IoTスタートアップにジョイン(ハード/ソフト/インフラ経験)
  • 経営企画(開発ブランク2年ほど…)
  • 当社digsasにCPOとしてジョイン

※↑は時系列順
血筋としてはフロントの人です。

本記事の構成

  • 当社の開発スタック
  • Fullstack TypeScriptでの簡易DDD
    • domain (entity/factory)
    • infra (repo)
    • usecase (query/mutation/hooks)
    • presentation (pages/components)
  • 課題、起きている問題など

対象者

  • フルスタック系のフレームワークでDDD構成をしたい方
  • 静的型付けによる恩恵をアプリ全体で享受したい方

digsasの開発スタック

全体像

Blitz.js を活用した作りになっています。

詳しくは後述しますが、以下のような作りになっています。

├ app/
│ └ {resources}/
│   ├ components
│   ├ domain
│   ├ hooks
│   ├ infra
│   ├ mutations
│   ├ pages
│   ├ queries
│   ├ store
│   └ usecases
│
├ db/
│ ├ migrations/
│ ├ seeds/
│ └ schema.prisma
│
└ infra/

利用しているツールと役割

  • Feedback収集/GISTプラニング: productboard
  • Prototyping: Figma
  • UI/View/パフォーマンス最適: React
  • データ保持/フロント上のデータ操作: Recoil
  • ダイナミックルーティング/最適化: Next.js
  • ユニットテスト: Jest
  • フロントエラー検知: Sentry
  • API Testing: Postman
  • ORM: prisma GraphQL
  • DB: PostgreSQL on Google Cloud SQL
  • CI: Github Actions
  • Build: Google Cloud Build
  • Server: Google Cloud Run
  • Logging: StackDriver
  • Jobs: Google Cloud Scheduling
  • Storage: Google Cloud Storage
  • Auth: Passport --> Auth0移行予定

解析系も仕込んでますが、そろそろSegmentやHeapなど使いたいです。

Fullstack TypeScriptでの簡易DDD

さて、ここから本題となりますが、当社はBlitz.jsのディレクトリに則りつつも以下のような構成にしています。
各リソースごとに分かれているため、どうしても入れられないものは core というディレクトリにまとめています。

├ app/
│ └ {resources}/
│   ├ components
│   ├ domain
│   ├ hooks
│   ├ infra
│   ├ mutations
│   ├ pages
│   ├ queries
│   ├ store
│   └ usecases

ケーススタディのため、当社の機能でもある「IT Issue」を例に、一連のフローを書いてみます。

IT Issueとは、「社内のIT状況におけるIssue」をプールし、たまったIssueをまとめてプロジェクトとして解決することができる機能です。

例えば、Slackで
「展示会後のSalesforceへの書き込みが非常にめんどいです、タスケテ…」
「SQL(Sales Qualified Lead)化率がわかりづらい!」
といった声が出てきたら、Slack Shortcut APIにて、これらを課題として起票できるものです。

IT導入プロジェクトを立ち上げ検討する際の火種として、これらをまとめて取り込んだり、ビジネスプロセスにマッピングすることで、課題の原因特定をしやすくすることができます。

※よくあるケーススタディのToDoに一番近そうなので、ピックアップしてみました
※わかりやすいように、簡素なデータモデルにしています

まずはドメイン

当社はまだアプリケーションとして大きすぎないこともあり、DDDとはいえかなり簡易的です。
ロジックが分離している状態、くらいのイメージです。

app/issues/domain/issueEntity.ts
export type IIssueEntity = {
  id: Id
  issuerId: Id
  title: IssueTitle // domain/valueObjects/issueTitle.ts
  status: IssueStatus // domain/valueObjects/issueStatus.ts
  issuer?: IUserEntity
}

export class IssueEntity implements IIssueEntity {
  id: Id
  issuerId: Id
  title: IssueTitle
  status: IssueStatus
  issuer?: UserEntity

  constructor(props: IIssueEntity) {
    this.id = new Id(props.id.value)
    this.issuerId = new Id(props.issuerId.value)
    this.title = new IssueTitle(props.title.value)
    this.status = new IssueStatus(props.status.value)
    this.issuer = props.issuer ? new UserEntity(props.issuer) : undefined
    Object.freeze(this)
  }

  get href() {
    return `/issues/${this.id.value}`
  }
  
  // サンプルロジック
  isCreatedBy(userId: Id) {
    return this.issuerId.is(userId.value)
  }
}

Id, IssueTitleなどのValueObjectsが出てきました。
これらは、 zod を利用したバリデーションをもたせています。

app/core に仕込んでいます。

app/core/domain/valueObjects/id.ts
import * as zod from "zod"

export const idSchema = zod.string().uuid()

export type IdValue = zod.infer<typeof idSchema>

export class Id {
  constructor(public value: IdValue) {
    idSchema.parse(value)
    Object.freeze(this)
  }
}

これで、 new Id(value) としたタイミングで、 uuid であるかどうかのバリデーションができるようになっています。

また、 Object.freeze をすることで、書き換え不可能な状態(更新するなら、再度インスタンス化が必要な状況)を作っています。

idSchema は、 mutationsにおける RequestBody のバリデーションに使います。

issueStatusなどの、リソースに紐づくvalueObjectは、 app/issues/domain/valueObjects/issueStatus と、リソースディレクトリに入れてます。

app/issues/domain/valueObjects/issueStatus.ts
import { IssueStatus as IssueStatusEnum } from "@prisma/client"
import * as zod from "zod"

export const issueStatusSchema = zod.enum([
  IssueStatusEnum.opened,
  IssueStatusEnum.closed
])

export type IssueStatusValue = zod.infer<typeof issueStatusSchema>

export class IssueStatus {
  constructor(public value: IssueStatusValue) {
    issueStatusSchema.parse(value)
    Object.freeze(this)
  }

  get isOpened() {
    return this.value === "opened"
  }

  get isClosed() {
    return this.value === "closed"
  }

  get label() {
    return translateIssueStatus(this.value)
  }
}

export const ALL_ISSUE_STATUSES: {
  value: IssueStatusValue
  label: string
}[] = [
  { value: "opened", label: "オープン" },
  { value: "closed", label: "クローズ" },
]

export const translateIssueStatus = (value: IssueStatusValue) => {
  return ALL_ISSUE_STATUSES.find((role) => role.value === value)?.label
}

Enumは、 @prisma/client による生成ファイルを参照して、マッピングしています。
これも、zodによるバリデーションが効く形になっているため、 ""を入れたりするとエラーが走ります。

また、 getterを書くのが面倒ではありますが、このような形にすることで、様々な箇所での条件文記述を、アクセサにできる(=後から拡張できる)ようになります。

labelに相当する部分も、ある意味ドメイン共通解と判断し、ここに入れてます。

null と undefinedを分ける(当たり前)

最初にあった、以下の部分ですが、 issuerの設計が結構大事です。

export type IIssueEntity = {
  id: Id
  issuerId: Id
  title: IssueTitle // domain/valueObjects/issueTitle.ts
  status: IssueStatus // domain/valueObjects/issueStatus.ts
  issuer?: IUserEntity
}

issuerId は必ずこのentityが持つべき値ですが、 そのIdに相当するデータが何であるかは常にentityが知らなくて良いものとしています。(optional)

絶対に知らないとダメ、にすることもできますが、その場合は、Repositoryで毎度JOIN(prismaのinclude)をつけることになります。

ちなみに、 issuerId がデータ上なくてもよい場合は、 issuerId: Id | null となります。
そして、 issuer?: IUserEntity | null という形になります。

データとして存在しないなら null, データが存在する可能性はあるけど、entityに定義していない状態を undefined として明確に区別しています。

entityを作るFactory

必要な部品を渡されて、entityを製造するだけの工場です。
domain層が膨らんでいなければ直下に、膨らんでるなら、 domain/factories/xxxFactory みたいにしています。

app/issues/domain/issueFactory.ts
import { Issue } from "@prisma/client"

import { Id } from "app/core/domain/valueObjects/id"
import { UserFactory, UserValue } from "app/users/domain/userFactory"

import { IssueEntity } from "./issueEntity"
import { IssueStatus } from "./valueObjects/issueStatus"

export type IssueValue = Issue & {
  issuer?: UserValue
}

export class IssueFactory {
  static fromRaw(values: IssueValue): IssueEntity {
    const {
      id,
      issuerId,
      title,
      status,
      issuer,
    } = values

    return new IssueEntity({
      id: new Id(id),
      issuerId: new Id(issuerId),
      title: new IssueTitle(title),
      status: new IssueStatus(status),
      issuer: issuer ? UserFactory.fromRaw(issuer) : undefined,
    })
  }
}

IssueValue@prisma/client から生成したtypeと、issuerを含むかどうかをoptionalで定義しています。

このIssueValueIssueFactory.fromRaw が受け取ることで、 IssueEntityが完成する形になっています。

fromRaw は部材が存在している(=読み込み)時などに使うことが多いですが、 createNewなどのstaticメソッドを作って、初期生成時はstatus固定、などもしやすいです。

大事なのは、このfactory->entityで常にTypeGuardが効いてることです。めちゃくちゃ楽。

インフラ層(repository)

この層では、主にDBへのアクセス、他社APIの利用をしています。

app/issues/infra/issueRepo.ts
import { Id } from "app/core/domain/valueObjects/id"
import db from "db"

import { IssueFactory } from "../domain/issueFactory"
import { IssueStatus } from "../domain/valueObjects/issueStatus"

export class IssueRepo {
  static async getIssues(input: { orgId: Id; take?: Take; skip?: Skip; status?: IssueStatus }) {
    const issues = await db.issue.findMany({
      where: {
        orgId: input.orgId.value,
        status: input.status?.value,
      },
      take: input.take?.value,
      skip: input.skip?.value,
      include: {
        issuer: true,
      },
    })

    const issueEntities = issues.map((issue) => IssueFactory.fromRaw(issue))

    return { issueEntities }
  }

  static async getIssue(input: { orgId: Id; issueId: Id }) {
    const issue = await db.issue.findFirst({
      where: {
        orgId: input.orgId.value,
        id: input.issueId.value,
      },
      include: {
        issuer: true,
      },
      rejectOnNotFound: true,
    })

    const issueEntity = IssueFactory.fromRaw(issue)

    return { issueEntity }
  }

  static async createIssue(input: {
    orgId: Id
    issuerId: Id
    title: IssueTitle
  }) {
    const issue = await db.issue.create({
      data: {
        orgId: input.orgId.value,
        issuerId: input.issuerId.value,
        title: input.title.value,
        status: "opened",
      },
    })

    const issueEntity = IssueFactory.fromRaw(issue)

    return { issueEntity }
  }
}

dbへのアクセスにおいて、if文は存在しないようにしつつ、optional程度のwhere指定はOKとしています。
Repoで持っているロジックは今の所、 rejectOnNotFound 程度です。
また、返却する時点でresource(issue)をentity化しています。

当社プロダクトはマルチテナントなので、orgIdを必ず指定するようにしており、これはauthorizeしていないと指定できない作りとなっているため、idハイジャックが起きないようになっています。

APIが必要な場合は、この層で、 isomorphic-unfetch の自作ラッパーや、公式提供ライブラリを利用しています。

Slackの例
import { WebClient } from "@slack/web-api"

export class SlackRepo {
  static async postMessage(foo) {
    const web = new WebClient(foo.token)
    return await web.chat.postMessage({ bar })
  }
}
unfetchの例
import unfetch from "utils/unfetch"

export class xxxRepo {
  static async createXXX(foo) {
    const res = await unfetch(url, options)
    return res
  }
}

domainとinfra層でusecaseを構築

Blitz.jsに則っているため、usecase = query/mutation となります。
例えば、 IT Issueを作成する場合は以下のような形です。

app/issues/mutations/createIssue.ts
import { resolver } from "blitz"
import * as zod from "zod"

import { Id, idSchema } from "app/core/domain/valueObjects/id"

import { IssueRepo } from "../infra/issueRepo"
import { IIssueEntity, IssueEntity } from "../domain/issueEntity"

export const createIssueInput = zod.object({
  title: issueTitleSchema,
})

export type CreateIssueInput = zod.infer<typeof createIssueInput>

export default resolver.pipe(
  resolver.zod(createIssueInput),
  resolver.authorize(),
  (input, ctx) => ({
    orgId, // middlewareで authorized orgIdを取得して、インスタンス化(new Id(orgId)
    issuerId, // 先行同様 userIdをインスタンス化
    title: new IssueTitle(input.title),
  }),
  async ({
    orgId,
    issuerId,
    title,
  }): Promise<{ issueEntity: IIssueEntity }> => {
    const { issueEntity } = await IssueRepo.createIssue({
      orgId,
      issuerId,
      title,
    })
    
    // サンプル条件文1
    if (issueEntity.status.isOpened) {
      const content = {
        href: issueEntity.href,
	message: `${issueEntity.title.value}${issueEntity.issuer.fullName}によって作成されました`
      }

      await NotificationRepo.createNotification({ content, to })
      await SlackRepo.postMessage({ content, to })
      await EmailRepo.send({ content, to })
    }
    
    // サンプル条件文2
    const userId = "xxxx-xxxx"
    if (issueEntity.isCreatedBy(userId)) {
      // 例外処理
    }

    return {
      issueEntity,
    }
  }
)

resolver.pipe() はBlitzで使える、疑似メソッドチェーンです。

まず、 zod.object による request inputのvalidationを行います。
TypeGuard恩恵を受けられます。(Swagger.codegenと同じイメージ)

次に resolver.authorize での認証チェック。
外部のAuthツールを使う場合は、ココ専用にmiddlewareを書くか、AuthRepoを用意すると良いです。

(input, ctx) => ({ ... }) で、request全てをvalueObject化。

最後の async () => Promise<hoge> 部分でユースケースの記述。
この中で、各ドメインが持つロジックを展開しつつ、Repoを利用しています。

Blitzjsにおいては、最後の返却値の型がフロントで読まれるようになりますが、実際はサーバーからフロントに返却する時点で、entityだったものは、メソッド等を失ったObjectになるため、インターフェースをPromiseで返していることを明示しています。

フロントでの呼び出し

Blitz.jsでは、これらのmutation/queryは、それぞれuseMutation/useQueryなどを利用して、どこからでも呼び出せることが一つのウリになっていますが、当社は、usecase層というものをフロント用に用意しています。

app/issues/usecases/useCreateIssue.ts
import { useMutation, useRouter } from "blitz"

import { useLoading } from "app/core/hooks/useLoading"
import { useToast } from "app/core/hooks/useToast"

import createIssue, { CreateIssueInput } from "../mutations/createIssue"

export const useCreateIssue = () => {
  const router = useRouter()
  const [createIssueMutation] = useMutation(createIssue)
  const { startLoading, endLoading } = useLoading()
  const { openToast } = useLoading()
  const { addIssueState } = useIssuesState()

  const handleCreateIssue = useCallback(async (input: CreateIssueInput, opts?: Options) => {
    startLoading()

    const { issueEntity } = await createIssueMutation(input)
    
    openToast({
      message: `${issueEntity.title.value}が作成されました`
    })
    
    if (opts.gotoDetailPageOnSuccess) {
      router.push(issueEntity.href)
    } else {
      addIssueState(issueEntity)
    }

    endLoading()
  }, [])
  
  return { handleCreateIssue }
}

各ユースケースごとに、このhooksを用意しており、フロントの様々な箇所から容易に利用できるようにしています。
(ちなみにエラーになった場合は、ErrorBoundary で拾って表示します)

Front Store との接続

addIssueState は詳細ページに遷移しない場合に、一覧表示に追加するための recoil用メソッドになります。
実は、フロントでもドメインロジックを使えるようにするため、この中で mutation/queryのレスポンスをentity化しています。

app/issues/store/useIssuesState.ts
import { useEffect } from "react"
import { atom, useRecoilState } from "recoil"

import { IIssueEntity, IssueEntity } from "../domain/issueEntity"

const issuesState = atom<IssueEntity[]>({
  key: "issues",
  default: [],
})

export const useIssuesState = () => {
  const [issues, setIssues] = useRecoilState(issuesState)

  const logics = {
    addIssueState(issue: IIssueEntity) {
      setIssues([...issues, new IssueEntity(issue)])
    },

    updateIssueState(issue: IIssueEntity) {
      setIssues(
        issues.map((item) => (item.id.is(issue.id.value) ? new IssueEntity(issue) : item))
      )
    },

    removeIssueStateById(issueId: Id) {
      setIssues(issues.filter((issue) => !issue.id.is(issueId.value)))
    },
  }

  return { issues, ...logics }
}

React View

フロントではこのようなにusecase hookを呼び出しています。

app/issues/components/IssueCreateButton.ts
import ModalPanel from "app/core/components/layouts/ModalPanel"
import { useModalPanel } from "app/core/hooks/useModalPanel"

import { useCreateIssue } from "../usecases/useCreateIssue"

import IssueFormCreate from "./IssueFormCreate"

const IssueCreateButton = () => {
  const { openModalPanel, closeModalPanel } = useModalPanel("CreateIssue")

  const { handleCreateIssue } = useCreateIssue()

  return (
    <>
      <Button primary label="起票する" onClick={() => openModalPanel()} />

      <ModalPanel name={"CreateIssue"} heading="起票する">
        <IssueFormCreate onSubmit={handleCreateIssue} onCancel={() => closeModalPanel()} />
      </ModalPanel>
    </>
  )
}

export default IssueCreateButton

Formの中身は、チームそれぞれ違うと思いますが、onSubmit/onCancel/onSuccessあたりを渡せるFormコンポーネントを用意しておけば、概ねどこでも使えると思います。

課題、起きている問題など

この構成で書き始めて約4ヶ月程度になりますが、TypeGuardによる恩恵が大きく、バリデーションのためのテストはほとんど要らなくなっています。

連携も、再利用も、機能削除などもしやすく、引き続きよい設計を模索していきたいところ。

ただ、DDDあるあるだとは思いますが、

  • WHEREが走りまくっているのでSQLコストを気にしないといけない
  • 非常に多くのインスタンス化が行われることもあり、スペックの低いPCだとパフォーマンスを気にしないといけない
  • サクッとプロトタイプ作りづらい(それが良くもあるのですが)
  • 追記:GraphQLの恩恵が半減している(SQLを意識しなくて良いだけでも十分か…?)

などの課題があります。

さいごに

初期設計の参考にさせていただいていたflitzコントリビューターの皆様に感謝です。

ざっくり設計の紹介をしてみましたが、この辺もうちょい知りたい!などあれば、お気軽にお声がけくださいませ。

FullstackのTypeScript環境もさながら、そこでDDDを取り入れている事例は少ないと思います。
私自身もブランクがあり、鋭意リハビリ中ですので、色々とご指摘いただけると幸いです。

  • フルスタックエンジニアへのキャリアに興味がある
  • TypeScript Love
  • スタートアップでテックリード・CTOしたい

な方は、ぜひ当社HPも御覧くださいませ。

https://digsas.com/recruit/1ToWkq9PlicK37uB3uSt8q

最後までありがとうございました!