🐼

TypeScriptでクリーンアーキテクチャを実践する

2024/03/01に公開

概要

本記事は、スクラムを管理するアプリケーションをクリーンアーキテクチャの考え方で実装し、WebからもCLIからも動かせるようにしたという実践を紹介するものです。学習のための個人開発で作成したサンプルアプリケーションの設計と実装を適宜紹介することで、クリーンアーキテクチャに対する理解を深めることが目的です。

モチベーション

なぜ現代の開発現場で定着しているクリーンアーキテクチャのアプリを手元で実装してみようと思ったかというと、私自身Webエンジニアとして働く中で、クリーンアーキテクチャの実践例は入出力をWebに限定したものばかりだったからです。

しかし、「詳細に依存せず抽象に依存すること」と唱えるクリーンアーキテクチャにとって、Webはただの詳細です。そこで、入力元、出力先を問わないアプリケーションはどのような書き味になるのか、自分で確かめてみたくなりました。

例えば、「ドメイン層は独立している」とはどのようなことを指すのか。ユースケース層は本当に入出力を問わず使いまわせるのか。Controller、Presentation、Repository 層はそれぞれどのように関連してくるのか。

そこで、自分自身が知っている「スクラム開発」を事例に個人開発のアプリケーションを作成しました。

スクラム開発を管理するアプリケーションを作成すると作業としては膨大になります。そこで、実装範囲を絞って「スクラムマスター、プロダクトオーナー、開発者といったスクラムチームのメンバーを管理する」機能だけでも上記の目的を達成することを目指しました。

結果、Web からも CLI からも、スクラムチームの人員の追加、変更、削除などの管理機能を実装することができました。

ドメイン層は完全に独立しておりどこにも依存せず、Pure な TypeScript で記述することができ、スクラムのドメインルールを雄弁に物語っています。ユースケース層はドメイン層や Repository 層を呼び出し、オブジェクトがダンスする場所になり、入出力がどこかを問わない構成になっているので、Web, CLI 以外の入出力の拡張にも耐えられる構成になりました。

抽象的な話が多くなりました。そろそろコードを交えてこのアプリケーションを紹介していきましょう。なお、本記事はスクラムの知識がなくても読める内容になっていますのでご安心ください。

なお、前半はバックエンドで動くコードの解説に費やしているため、フロントエンドエンジニアの方は Next.js を使っている Web の記述をメインに読んでもらうと良いかと思います。

webアプリケーション
Web アプリケーションの画面。右カラムにスクラムチームのメンバーを表示している

CLIアプリケーション
CLI アプリケーションの画面。スクラムチームのメンバーを一覧表示するコマンドを実行したところ。Web と同じメンバーが同じ役職で表示されている

https://github.com/KushibikiMashu/scrum/

使用技術

使用技術は以下の通りです。

  • 言語: TypeScript
  • Web: Next.js(App Router), Tailwind CSS
  • CLI: commander.js, @inquirer/prompts
  • DB: lowdb

また、モノレポ構成によりパッケージに分けて関心を分離しています。

プロジェクト構成

プロジェクト構成は以下の通りです。packages にはドメイン層、ユースケース層、設定ファイルなどが含まれています。apps には Web アプリケーションと CLI アプリケーションが含まれています。

.
├── README.md
├── apps
│   ├── cli // CLI アプリケーション
│   └── web // Web アプリケーション
├── db.json
├── package.json
├── packages
│   ├── config // 設定ファイル
│   ├── core // ドメイン層
│   └── use-case // ユースケース層
├── turbo.json
└── yarn.lock

packages/config は今回の関心ごとではないので特に触れません。

ドメインモデル図

コーディングに入る前に、スクラムに対してドメインモデリングを実施しました。

ドメインモデリングとは、特定のドメイン(領域)の知識を体系的に整理し、モデル化することです。つまり、その領域に登場する構成要素とその特徴、構成要素同士の関係性、制約を文章、図、特定の記法によって表現することです。

今回のターゲットはスクラムであるので、スクラムガイド を読みながら登場人物を文章から抜き出し、どのようなルールで動いているのかを抽出し、Figjam で整理しました。

スクラムガイドの本文
スクラムガイドの本文

付箋一覧
本文を付箋に抜き出す

スクラムチームに関する付箋
スクラムチームに関する付箋

スクラムチームに関するメインのルールで、かつコードに落とし込めるものは以下の一文です。

スクラムチームは、スクラムマスター1⼈、プロダクトオーナー1⼈、複数⼈の開発者で構成される。通常は 10 ⼈以下である。

この文章で書かれたルールを mermaid 記法でドメインモデル図として表現していきます。

classDiagram
    class ScrumTeam {
        %% ProductOwner とか ScrumMaster は ID 参照の方がいいかもしれないが、
        %% あえてインスタンス参照にしてみる
        +ProductOwner productOwner
        +ScrumMaster scrumMaster
        +Developer[] developers

        +create(productOwner ProductOwner, scrumMaster ScrumMaster, developers Developer[])
        +addTeamMember(Member member) this
        +removeTeamMember(Member member) void
        +disband() %% 解散する
    }
    note for ScrumTeam "スクラムチームは、\n・スクラムマスター1⼈\n・プロダクトオーナー1⼈\n・8⼈以下の開発者\nで構成される"
    
    class ProductOwner {
    }
    ScrumTeam --> ProductOwner : has

    class ScrumMaster {
    }
    ScrumTeam --> ScrumMaster : has

    class Developer {
    }
    ScrumTeam --> Developer : has

ドメインモデル図の全体はGitHubに掲載しています。

ドメイン層

ドメインを分析し、ドメインモデル図の作成が終わりました。次は、この図をもとにプログラミング言語で実装していきます。今回は TypeScript を選びました。

プロジェクト内ではpackages/coreにドメイン層を実装しています。具体的には、ドメインルールを表現するためのクラスやインターフェースを配置しています。

.
├── index.ts
├── jest.config.js
├── node_modules
├── package.json
├── src
│   ├── common
│   │   ├── id.ts
│   │   ├── index.ts
│   │   ├── tests
│   │   └── time.ts
│   ├── company # 会社
│   │   ├── employee.ts # 社員
│   │   ├── index.ts
│   │   └── tests
│   ├── index.ts
│   └── scrum
│       ├── artifact # 制作物
│       ├── index.ts
│       ├── product # スクラムチームが作成するプロダクト
│       ├── scrum-event # スクラムイベント
│       └── team # スクラムチーム
├── tsconfig.json
└── yarn.lock

今回はsrc/scrum/teamに絞って解説していきます。ドメインモデル図に従い、スクラムチームをコードで実装すると以下のようになりました。

import { Id } from '@/common'
import { Member } from '@/company'

export const ScrumMemberRole = {
  ProductOwner: 'product_owner',
  ScrumMaster: 'scrum_master',
  Developer: 'developer',
} as const

export type ScrumMemberRoleType = (typeof ScrumMemberRole)[keyof typeof ScrumMemberRole]

export class ScrumTeamId extends Id {
  constructor(public readonly value: number | null) {
    super(value)
  }

  static createAsNull() {
    return new ScrumTeamId(null)
  }

  equals(id: ScrumTeamId) {
    return this.value === id.value
  }
}

export class ScrumTeam {
  constructor(
    public readonly id: ScrumTeamId,
    public readonly productOwner: ProductOwner,
    public readonly scrumMaster: ScrumMaster,
    public readonly developers: Developer[]
  ) {
  }
}

export class ProductOwner {
  constructor(
    public readonly roles: ScrumMemberRoleType[],
    public readonly member: Member
  ) {
    this.validate()
  }

  private validate() {
    if (this.roles.includes(ScrumMemberRole.ScrumMaster)) {
      throw new Error('ProductOwner cannot be ScrumMaster')
    }
    if (!this.roles.includes(ScrumMemberRole.ProductOwner)) {
      throw new Error('ProductOwner must have ProductOwnerRole')
    }
  }
}

export class ScrumMaster {
  constructor(
    public readonly roles: ScrumMemberRoleType[],
    public readonly member: Member
  ) {
    this.validate()
  }

  private validate() {
    if (this.roles.includes(ScrumMemberRole.ProductOwner)) {
      throw new Error('ScrumMaster cannot be ProductOwner')
    }
    if (!this.roles.includes(ScrumMemberRole.ScrumMaster)) {
      throw new Error('ScrumMaster must have ScrumMasterRole')
    }
  }
}

export class Developer {
  public readonly role: ScrumMemberRoleType = ScrumMemberRole.Developer

  constructor(public readonly member: Member) {}
}

ドメイン層のコードの特徴

各クラスはプロパティと不変条件の validation(生成時の制約)を持っています。このコードの特徴は3点あります。

まず、各クラスには主なメソッドが書かれていませんが、それはユースケース層を実装するタイミングで必要になった時に実装すればいいので、ここでは実装していません。YAGNI ですね。

次に、ドメイン分析で「スクラムチームは、スクラムマスター1⼈、プロダクトオーナー1⼈、複数⼈の開発者で構成される」というルールがあることがわかっています。このため、プロダクトオーナー(PO)とスクラムマスター(SM)の兼務を禁止しています。

export class ProductOwner {
  ...

  private validate() {
    if (this.roles.includes(ScrumMemberRole.ScrumMaster)) {
      throw new Error('ProductOwner cannot be ScrumMaster') // プロダクトオーナーはスクラムマスターになれない
    }
    ...
  }
}

export class ScrumMaster {
  ...

  private validate() {
    if (this.roles.includes(ScrumMemberRole.ProductOwner)) {
      throw new Error('ScrumMaster cannot be ProductOwner') // スクラムマスターはプロダクトオーナーになれない
    }
    ...
  }
}

一方、スクラムガイドには以下のような記述があります。

プロダクトオーナーまたはスクラムマスターがスプリントバックログのアイテムに積極的に取り組んでいる場合は、開発者として参加する。

このため、POやSMが開発者を兼任することは禁止していません。このため、不変条件である validate には特にその制限は設けていません。

最後に、この TypeScript のコードは外部に依存していません。全部自分で書いた、スクラムというドメインに閉じたコードです。同じパッケージ内の common(ID 管理のため)と company(社員管理のため)にしか依存しておらず、core パッケージ内で完結しています。これはドメイン層が独立しているという証拠です。

ドメイン層は、Java が POJO(Plain Old Java Object) と言ったり PHP が POPO(Plain Old PHP Object) と呼んでいるもので表現します。今回は何の外部ライブラリにも依存しておらず、スクラムチームの関係性というドメインモデルの記述ができているので、この思想を体現していると言えます。

この packages/core の部分がクリーンアーキテクチャの同心円の中心部分 Entities 、つまりドメイン層を構成します。

クリーンアーキテクチャの図
https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

次に紹介するユースケース層は、これらのドメイン層のオブジェクトを呼び出し、様々なオブジェクトがメソッドを実行する場所になります。詳しく見ていきましょう。

ユースケース層

ユースケース層のディレクトリ構成を以下に示します。

use-case
├── index.ts
├── jest.config.js
├── package.json
├── src
│   ├── application // ユースケース層
│   │   ├── query-service
│   │   ├── scenario
│   │   └── use-case
│   ├── external // DBのライブラリ
│   │   └── lowdb
│   ├── gateway // 外部の永続化先を扱う実クラス
│   │   ├── adapter
│   │   └── repository
│   └── index.ts
└── tsconfig.json

さらに、src/application配下は以下のようになっています。

application
├── query-service // QueryService
│   ├── cli
│   ├── index.ts
│   └── web
├── scenario // 複数の UseCase を呼び出すときに作る
│   └── init
└── use-case // UseCase
    ├── employee
    ├── product
    ├── project
    └── scrum-team

この構成の特徴は、コマンドクエリ分離原則を意識していることです。

CQRSの図
「CQRS & Event Sourcing」 より

CQRS(コマンドクエリ分離原則)

上記のuse-caseディレクトリに着目すると、scrum-team, productなどの操作対象に対して、一つの UseCase を実装しています。この UseCaseは write のみを担当しており、read は query-service というディレクトリにあるクラス群が担うことになります。

UseCase は単一なのに、QueryService は web と cli があります。これは、UseCase の入力を抽象化しているため可能になったことです。これはクリーンアーキテクチャの特徴の一つです。

UseCase は操作対象に対して一つ(DDDでは集約と呼ばれる単位になるはずです)、QueryService は入出力先に対してそれぞれ実装することでコードの重複を排除し、かつ柔軟な設計になっています。

簡単に書くと、UseCase(Command): voidQueryService(WebInput): WebDtoQueryService(CLIInput): CLIDto という分け方になっています。

データフロー
データの流れを図示した

UseCase の実装

具体的にコードを見ていきましょう。まずは ScrumTeam を操作する UseCase です。

import {
  Developer,
  EmployeeRepositoryInterface,
  ProductOwner,
  ScrumMaster,
  ScrumTeam,
  ScrumTeamRepositoryInterface,
} from '@panda-project/core'

import {
  EditScrumTeamCommand,
  CreateScrumTeamCommand,
  DisbandScrumTeamCommand,
  AddDeveloperCommand,
  RemoveDeveloperCommand,
} from '@/application/use-case/scrum-team'
import { EmployeeRepository, ScrumTeamRepository } from '@/gateway/repository/json'

export class ScrumTeamUseCase {
  constructor(
    private readonly scrumTeamRepository: ScrumTeamRepositoryInterface = new ScrumTeamRepository(),
    private readonly employeeRepository: EmployeeRepositoryInterface = new EmployeeRepository()
  ) {}

  async create(command: CreateScrumTeamCommand) {
    const { newProductOwner, newScrumMaster, developers } = await this.createScrumMembersFromCommand(command)

    const scrumTeamExists = await this.scrumTeamRepository.exists()
    if (scrumTeamExists) {
      throw new Error('スクラムチームはすでに作成されています')
    }

    const count = await this.employeeRepository.count()
    if (count <= 1) {
      throw new Error(`スクラムチームを作成するためには、社員が2人以上登録されている必要があります。社員数: ${count}`)
    }

    const newScrumTeam = ScrumTeam.createNew(newProductOwner, newScrumMaster, developers)
    await this.scrumTeamRepository.save(newScrumTeam)
  }

  async edit(command: EditScrumTeamCommand) {
    const { newProductOwner, newScrumMaster, developers } = await this.createScrumMembersFromCommand(command)

    const prevScrumTeam = await this.scrumTeamRepository.fetchOrFail()
    const newScrumTeam = prevScrumTeam
      .changeProductOwner(newProductOwner)
      .changeScrumMaster(newScrumMaster)
      .updateDevelopers(developers)
    await this.scrumTeamRepository.update(newScrumTeam)
  }

  async disband(command: DisbandScrumTeamCommand) {
    const scrumTeamId = command.getScrumTeamId()
    const scrumTeam = await this.scrumTeamRepository.fetchOrFail()

    if (!scrumTeam.id.equals(scrumTeamId)) {
      throw new Error('削除しようとしているスクラムチームは存在しません')
    }

    await this.scrumTeamRepository.delete()
  }
}

先ほど紹介した ScrumTeam クラスにはメソッドはchangeProductOwnerupdateDevelopersといったメソッドはまだ実装していませんでした。UseCase を記述する中であるメソッドが必要になったときに初めてドメインオブジェクトにそれを実装しましょう。すると開発に無駄がなくなります(YAGNI 原則)。

ここでは「UseCase の各メソッドを書いたときに必要性に駆られて、ScrumTeam クラスに各メソッドを実装したのだ」と捉えてください。

さて、この UseCase コードの特徴は、各メソッドでcreate(command: CreateScrumTeamCommand)などとして、コマンドオブジェクトを受け取っているところです。

これらのコマンドは実クラスではなく、実際は以下のような interface です。

export interface CreateScrumTeamCommand {
  getProductOwnerId(): EmployeeId
  getScrumMasterId(): EmployeeId
  getDeveloperIds(): EmployeeId[]
}

このインターフェースを implements する具象クラスを、入出力先の数だけ実装します。すると、UseCase は CommandInterface という抽象に依存し、実クラスという具象に依存しない作りになります。つまり UseCase はどの入力元から Command が来たのかを知らずに済む設計になるのです。

// CLI からの入力
export class CreateScrumTeamCliCommand implements CreateScrumTeamCommand {
  constructor(
    private readonly productOwnerId: number,
    private readonly scrumMasterId: number
  ) {}

  getProductOwnerId(): EmployeeId {
    return new EmployeeId(this.productOwnerId)
  }

  getScrumMasterId(): EmployeeId {
    return new EmployeeId(this.scrumMasterId)
  }

  getDeveloperIds(): EmployeeId[] {
    // CLI の場合、チーム作成時に開発者は指定しない
    return []
  }
}
// Web からの入力
export class CreateScrumTeamWebCommand implements CreateScrumTeamCommand {
  constructor(
    private readonly productOwnerId: string,
    private readonly scrumMasterId: string,
    private readonly developerIds: string[]
  ) {}

  getProductOwnerId(): EmployeeId {
    return new EmployeeId(Number.parseInt(this.productOwnerId, 10))
  }

  getScrumMasterId(): EmployeeId {
    return new EmployeeId(Number.parseInt(this.scrumMasterId, 10))
  }

  getDeveloperIds(): EmployeeId[] {
    const filteredIds = this.developerIds.filter((id) => id !== '')

    // 重複の有無をチェック。ID の重複を排除するために Set を使う
    const uniqueIds = new Set(filteredIds)
    if (uniqueIds.size !== filteredIds.length) {
      throw new Error('開発者が重複しています')
    }

    return filteredIds.map((id) => new EmployeeId(Number.parseInt(id, 10)))
  }
}

2つのコマンドの実装を見比べると実は全部異なっています。CLI は数値を受け取れるのでNumber.parseIntは不要ですが、Web は Form 経由で文字列を受け取るためNumber.parseIntによる数値への変換が必要です。

また、Web は開発者を複数指定できるため、ID の重複チェックを行っています。しかし、CLI ではスクラムチームを作成する際に開発者を指定できないため、このようなチェックは不要です。

入力元の違いによる詳細な差異を、各 Command の具象クラスが吸収しているのです。CommandInterface は UseCase 層に、Command の具象クラスはその外部(Gateway)に実装すると良いでしょう。これがクリーンアーキテクチャで実装する UseCase の特徴です。

QueryService の実装

次に、Web で ScrumTeam を取得する QueryService を紹介します。QueryService は read を担うものでしたね。

QueryService は出力先の要請に従って DTO(data transfer object)を返します。DTO は使い回すものではなく、QueryService と1対1対応をさせた方が良いので QueryService のクラスと同じファイルに実装しています。

import { ScrumTeamRepositoryInterface } from '@panda-project/core'

import { Result } from './types'

import { ScrumTeamRepository } from '@/gateway/repository/json'

export type ScrumTeamQueryServiceDto = {
  scrumTeam: {
    scrumMaster: {
      employeeId: number
      name: string
      isDeveloper: boolean
    }
    productOwner: {
      employeeId: number
      name: string
      isDeveloper: boolean
    }
    developers: {
      employeeId: number
      name: string
    }[]
  } | null
}

export class ScrumTeamQueryService {
  constructor(private readonly scrumTeamRepository: ScrumTeamRepositoryInterface = new ScrumTeamRepository()) {}

  async exec(): Promise<Result<ScrumTeamQueryServiceDto>> {
    try {
      const { scrumMaster, productOwner, developers } = await this.scrumTeamRepository.fetchOrFail()
      // presentation logic
      return {
        data: {
          scrumTeam: {
            scrumMaster: {
              employeeId: scrumMaster.getEmployeeId().toInt(),
              name: scrumMaster.getFullName(),
              isDeveloper: scrumMaster.isDeveloper(),
            },
            productOwner: {
              employeeId: productOwner.getEmployeeId().toInt(),
              name: productOwner.getFullName(),
              isDeveloper: productOwner.isDeveloper(),
            },
            developers: developers.map((developer) => ({
              employeeId: developer.getEmployeeId().toInt(),
              name: developer.getFullName(),
            })),
          },
        },
        error: null,
      }
    } catch {
      return {
        data: { scrumTeam: null },
        error: null,
      }
    }
  }
}

この QueryService で得られたデータは、下記画像の垢枠内で表示されます。

スクラムチームを表示する
赤枠で囲まれたところが QueryService で取得したデータの表示箇所

次に、CLI で ScrumTeam を取得する QueryService は以下です。

import { ScrumTeamRepositoryInterface } from '@panda-project/core'

import { ScrumTeamRepository } from '@/gateway/repository/json'

type Dto = {
  poName: string
  smName: string
  developerNames: string[]
}

export class ListScrumTeamQueryService {
  constructor(private readonly scrumTeamRepository: ScrumTeamRepositoryInterface = new ScrumTeamRepository()) {}

  async exec(): Promise<Dto> {
    const { productOwner, scrumMaster, developers } = await this.scrumTeamRepository.fetchOrFail()
    return {
      poName: productOwner.getFullName(),
      smName: scrumMaster.getFullName(),
      developerNames: developers.map((developer) => developer.getFullName()),
    }
  }
}

CLIアプリケーション
CLI でスクラムチームのメンバーを一覧表示するコマンドを実行したところ

興味深いのは、Web も CLI もともにthis.scrumTeamRepository.fetchOrFail()を実行してスクラムチームに関するデータを DB から取得しているものの、DTO の形がまるで異なっていることです。これは異なる画面の要請に従った結果です。

この柔軟性を担保するのが QueryService の役目です。データの Input を担う Command と同じように、OutPut を担う QueryService も入出力先の数だけ実装すると変更に強い柔軟な設計になるでしょう。

インフラ層

インフラ層の大きな特徴は、RepositoryInterface をドメイン層に置くことで永続化先を柔軟に変えられることです。

まずはディレクトリ構成を見てみましょう。

gateway
└── repository
    └── json
        ├── index.ts
        ├── json-repository.ts
        ├── scrum-team-repository.ts
        └── tests

Repository パターンはデータの永続化を担当するクラスを実装するためのパターンです。今回は lowdb という JSON ファイルを簡易的な DB として扱うライブラリを使用しているため、repository/jsonというディレクトリを作成しています。

説明を容易にするため、ScrumTeamRepositoryの save メソッドのみを抜粋しています。

import {
  ScrumTeam,
  ScrumTeamId,
  ScrumTeamRepositoryInterface,
} from '@panda-project/core'

import { Low } from 'lowdb'

import { JsonRepository } from './json-repository'

import {
  DataBase,
  db,
} from '@/external/lowdb'

export class ScrumTeamRepository extends JsonRepository implements ScrumTeamRepositoryInterface {
  constructor(private readonly lowdb: Low<DataBase> = db) {
    super()
  }

  private nextId(): ScrumTeamId {
    return new ScrumTeamId(this.calculateNewId(this.lowdb.data.scrumTeams))
  }

  async save(scrumTeam: ScrumTeam) {
    await this.lowdb.read()
    const {scrumTeams, productOwners, scrumMasters, developers} = this.lowdb.data

    // scrum team を保存
    const scrumTeamId = this.nextId()
    scrumTeams.push({
      id: scrumTeamId.toInt(),
    })

    // product owner を保存
    productOwners.push({
      scrum_team_id: scrumTeamId.toInt(),
      employee_id: scrumTeam.productOwner.getEmployeeId().toInt(),
    })

    // scrum master を保存
    scrumMasters.push({
      scrum_team_id: scrumTeamId.toInt(),
      employee_id: scrumTeam.scrumMaster.getEmployeeId().toInt(),
    })

    // developer を保存
    for (const scrumTeamDeveloper of scrumTeam.developers) {
      developers.push({
        scrum_team_id: scrumTeamId.toInt(),
        employee_id: scrumTeamDeveloper.getEmployeeId().toInt(),
      })
    }

    await this.lowdb.write()
  }
}

モジュールの依存関係に注目すると、Repository はドメイン層の ScrumTeamId ScrumTeam ScrumTeamRepositoryInterface に依存しています。

ScrumTeamRepositoryInterface は必ずドメイン層に配置しましょう。ドメイン層には変わりにくいものを配置します。前述の UseCase や QueryService も JSON ファイルに接続する Repository ではなく、抽象であるScrumTeamRepositoryInterfaceに依存しています。

仮に永続化先に MySQL を選択できるようにするなら、repository/mysqlというディレクトリを作成し、その中に scrum-team-repository.tsを実装していたでしょう。その場合ScrumTeamRepositoryの定義は以下のように親クラスを変えて、interface が要求する各メソッドを実装するだけで済みます。

class ScrumTeamRepository extends MySqlRepository implements ScrumTeamRepositoryInterface {
  async save(scrumTeam: ScrumTeam) {
    ...
  }
}

MySQL に接続する Repository を UseCase や QueryService で使う場合は、DI の定義を変更して JsonRepository から MySqlRepository に差し替えるだけです。

JsonRepository も MySqlRepository も同じScrumTeamRepositoryInterfaceを実装しています。このため、interface で定義された全てのメソッドの引数と返り値は同じ方を要求します。

このため、Repository の具象クラスが変わって永続化先が変更されたとしても、UseCase や QueryService 内のコードは書き換えずに済むのです。

packages まとめ

ここまでで、外部の入出力先、また DB といった永続化先に依存しない独立したアプリケーションが作成できました。ここからは実際に画面からの入力や表示を行い、ユーザーの方が使えるサービスとして実装するための関心ごとを記述していきます。

アプリケーションとして動かす

以下では、apps/webapps/cliの解説をしていきます。

Web からアプリケーションを動かす

Web の実装は Next.js を使いました。学習を兼ねて App Router で実装しています。

Server Component を使ってデータを fetch し(read)、Server Actions を使ってフォームを submit できる(write)ため、API 通信のためのライブラリを考慮する必要がなくなるという簡便さを選択しました。

apps/webディレクトリは以下のような構成になっています。

web
├── README.md
├── next-env.d.ts
├── next.config.js
├── package.json
├── postcss.config.js
├── src
│   ├── app
│   │   ├── (product)
│   │   ├── (root)
│   │   ├── actions.ts
│   │   ├── globals.css
│   │   ├── initial-form.tsx
│   │   ├── layout.tsx
│   │   └── page.tsx
│   ├── components
│   │   ├── common
│   │   ├── feature
│   │   ├── global
│   │   └── layout
│   ├── hooks
│   │   └── index.ts
│   └── utils.ts
├── tailwind.config.ts
├── todo.md
└── tsconfig.json

Web におけるスクラムチームに関連するページとコンポーネントを紹介します。

データの Read

まず Read 面です。こちらは WebQueryServiceを使ってデータを取得していましたね。想像しやすいと思うので、まずは画面を見てみましょう。

スクラムチームを表示する
赤枠で囲まれたところが QueryService で取得したデータの表示箇所

このページのコンポーネントは以下のような実装になっています。

// team/page.tsx
import { ScrumTeamQueryService } from '@panda-project/use-case'
import Link from 'next/link'

import { EmptyTeam } from '~/app/(product)/[product]/_common/empty-team'
import { BreadcrumbContainer } from '~/components/layout/breadcrumb'

import TaskList from '../_common/task-list'

import Stats from './stats'
import Team from './team'

export default async function TeamPage() {
  const { data } = await new ScrumTeamQueryService().exec()

  if (data === null || data.scrumTeam === null) {
    return (
      <div className="px-4 py-6 sm:px-6 lg:pl-8 xl:flex-1 xl:pl-6">
        <BreadcrumbContainer current={{ name: 'スクラムチーム' }} />
        <div className="mt-4">
          <EmptyTeam href="./team/edit" />
        </div>
      </div>
    )
  }

  const { scrumTeam } = data

  return (
    <div className="flex flex-col">
      <div className="mx-auto w-full max-w-7xl grow lg:flex">
        <div className="flex-1 xl:flex">
          <div className="px-4 py-6 sm:px-6 lg:pl-8 xl:flex-1 xl:pl-6">
            <BreadcrumbContainer current={{ name: 'スクラムチーム' }} />
            <div className="mt-6">
              <Stats />
            </div>
            <div className="mt-8">
              <TaskList />
            </div>
          </div>
        </div>

        {/* sidebar */}
        <div className="shrink-0 border-t border-gray-200 px-4 py-6 sm:px-6 lg:w-96 lg:border-l lg:border-t-0 lg:pr-8 xl:pr-6">
          <div className="text-right">
            <Link className="text-xs border border-gray-300 hover:bg-gray-50 rounded-md px-3 py-2" href="./team/edit">
              編集する
            </Link>
          </div>
          <div className="mt-4">
            <Team scrumTeam={scrumTeam} />
          </div>
        </div>
      </div>
    </div>
  )
}

このコンポーネントの特徴は、データ取得がシンプルなことです。以下の一行でこのページが欲しいデータが全て取得できています。

const { data } = await new ScrumTeamQueryService().exec()

この QueryService はユースケース層のところで紹介したものでしたね。

幸いこのページでは DB から ScrumTeam のデータしか要求していないですが、もし他のデータも必要であれば、別の QueryService を作成して呼び出すことになるでしょう。

QueryService が渡してくれる DTO の型は TS 同士で共有できているため、知らないうちにエラーになることもありません。React がデータ取得の詳細を知らずに済むため、自分でコードを書きながらかなり強力な書き方だなと思っていました。

実際は API をコールするのと同じようなデータの受け取り方ではあるのですが、Server Component での実装でありバックエンドで実行されるコードであるため API リクエストのオーバーヘッドがなく、QueryService の呼び出しが増えてもパフォーマンスの劣化はかなり避けられるのではないでしょうか(この一文はちょっと自信ないですが)。

なお、React は Presentation 層であるため、リストの特殊な並び替えや日付表示の形式の変更などの表示からの仕様要求があれば、React 内で対応すると良いでしょう。

データの Write

次に Write 面についてです。TeamForm コンポーネントを使ってチームの新規作成と更新をします。

TeamForm コンポーネント
TeamForm コンポーネント

'use client'

import { ScrumTeamEditQueryServiceDto } from '@panda-project/use-case'
import { useFormState, useFormStatus } from 'react-dom'

import { ErrorMessage } from '~/components/common/error-message'
import { SubmitButton } from '~/components/common/submit-button'

import { updateTeam } from './actions'

type Props = Pick<ScrumTeamEditQueryServiceDto, 'scrumTeam' | 'employees'>

function Submit() {
  const { pending } = useFormStatus()
  return <SubmitButton label="保存する" type="submit" pending={pending} />
}

const initialState: {
  errors:
    | {
        productOwnerId: string[]
        scrumMasterId: string[]
        developerIds: string[]
      }
    | string[]
    | null
} = { errors: null }

export function TeamForm({ scrumTeam, employees }: Props) {
  const filteredEmployees = employees.map((employee) => ({
    id: employee.id,
    name: employee.fullName,
  }))
  const developersMaxCount = Math.max(Math.min(8, filteredEmployees.length), 1)
  const [state, action] = useFormState(updateTeam, initialState)

  return (
    <div>
      <ErrorMessage messages={state.errors} />

      <form className="space-y-4" action={action}>
        {/* 新規作成と更新を区別するために team id を送る */}
        <input type="hidden" name="scrum-team-id" value={scrumTeam?.id ?? ''} />

        <div className="space-y-2">
          <p className="block text-sm font-medium leading-6 text-gray-900">プロダクトオーナー*</p>
          <div>
            <select
              className="w-full cursor-default rounded-md bg-white py-1.5 pl-3 pr-10 text-left text-gray-900 shadow-sm border-gray-300 focus:outline-none sm:text-sm sm:leading-6"
              name="product-owner-id"
              required
              defaultValue={scrumTeam?.productOwner.employeeId ?? ''}
            >
              <option value="" disabled>
                ---
              </option>
              {filteredEmployees.map((employee) => (
                <option key={employee.id} value={employee.id}>
                  {employee.name}
                </option>
              ))}
            </select>
          </div>
        </div>
        <div className="space-y-2">
          <p className="block text-sm font-medium leading-6 text-gray-900">スクラムマスター*</p>
          <div>
            <select
              className="w-full cursor-default rounded-md bg-white py-1.5 pl-3 pr-10 text-left text-gray-900 shadow-sm border-gray-300 focus:outline-none sm:text-sm sm:leading-6"
              name="scrum-master-id"
              required
              defaultValue={scrumTeam?.scrumMaster.employeeId ?? ''}
            >
              <option value="" disabled>
                ---
              </option>
              {filteredEmployees.map((employee) => (
                <option key={employee.id} value={employee.id}>
                  {employee.name}
                </option>
              ))}
            </select>
          </div>
        </div>
        <div className="space-y-2">
          <p className="block text-sm font-medium leading-6 text-gray-900">開発者(最大8名)</p>
          <div className="space-y-2">
            {[...Array(developersMaxCount)].map((_, i) => {
              const defaultValue = scrumTeam?.developers[i]?.employeeId ?? ''
              return (
                <div key={i}>
                  <select
                    className="w-full cursor-default rounded-md bg-white py-1.5 pl-3 pr-10 text-left text-gray-900 shadow-sm border-gray-300 focus:outline-none sm:text-sm sm:leading-6"
                    name="developers"
                    defaultValue={defaultValue}
                  >
                    <option value="">---</option>
                    {filteredEmployees.map((employee) => (
                      <option key={employee.id} value={employee.id}>
                        {employee.name}
                      </option>
                    ))}
                  </select>
                </div>
              )
            })}
          </div>
        </div>

        <div className="mt-4 text-right">
          <Submit />
        </div>
      </form>
    </div>
  )
}

このコンポーネントではuseFormState を使って Server Action 実行しています。ただ、このコンポーネントは何の変哲もないのですが、むしろ Server Action の記述が重要です。

'use server'

import {
  EditScrumTeamWebCommand,
  CreateScrumTeamWebCommand,
  ScrumTeamUseCase,
} from '@panda-project/use-case'

import { redirect } from 'next/navigation'
import { z } from 'zod'

export const updateTeam = async (_: any, formData: FormData) => {
  const schema = z.object({
    scrumTeamId: z.string(),
    productOwnerId: z.string(),
    scrumMasterId: z.string(),
    developerIds: z.array(z.string()).min(0).max(10),
  })

  try {
    const parsed = schema.parse({
      scrumTeamId: formData.get('scrum-team-id'),
      productOwnerId: formData.get('product-owner-id'),
      scrumMasterId: formData.get('scrum-master-id'),
      developerIds: formData.getAll('developers'),
    })

    const isCreate = parsed.scrumTeamId === ''
    if (isCreate) {
      const command = new CreateScrumTeamWebCommand(parsed.productOwnerId, parsed.scrumMasterId, parsed.developerIds)
      await new ScrumTeamUseCase().create(command)
    } else {
      const command = new EditScrumTeamWebCommand(parsed.productOwnerId, parsed.scrumMasterId, parsed.developerIds)
      await new ScrumTeamUseCase().edit(command)
    }
  } catch (e: unknown) {
    if (e instanceof z.ZodError) {
      return {
        errors: {
          ...e.formErrors.fieldErrors,
        },
      }
    }

    return {
      errors: e instanceof Error ? [e.message] : [],
    }
  }

  redirect('./')
}

とてもシンプルなコードではないでしょうか。上述したように、Web からの入力値を UseCase に渡す値に変換する処理は CreateScrumTeamWebCommandEditScrumTeamWebCommand が担っています。これにより、string を数値に変換したり細かいデータ処理はここでは不要です。

Server Action は zod によるフォームの値のバリデーションと UseCase の実行、エラーハンドリングのみを主な責務として割り切っているため、シンプルなコードになっています。。

Server Action は比較的自由にコードが書けてしまうため、責務を明確に分離していない場合はコードが肥大化したり荒れて読みにくくなる可能性があります。しかし、クリーンアーキテクチャの原則に則って設計をしていると Next.js の Server Action という新技術が登場しても、このように責務を適切に分けることでクリーンなコードを書くことができるのです。

CLI からアプリケーションを動かす

最後に CLI の構成を見ていきましょう。こちらも Web と同様に同心円の一番外であるという扱いです。ディレクトリ配下には各コマンドごとにファイルを作成しています。

CLI
├── package.json
├── src
│   ── developer
│   │   ├── add-developer.ts
│   │   ├── index.ts
│   │   └── remove-developer.ts
│   ├── employee
│   │   ├── add-employee.ts
│   │   ├── edit-employee.ts
│   │   ├── index.ts
│   │   └── remove-employee.ts
│   ├── index.ts
│   ├── init.ts
│   └── team
│       ├── create-team.ts
│       ├── disband-team.ts
│       ├── edit-team.ts
│       ├── index.ts
│       └── list-team.ts
├── tsconfig.json
└── yarn.lock

実際に以下のようなコマンドを作成しています。

$ yarn scrum help
Usage: index [options] [command]

Options:
  -h, --help              display help for command

Commands:
  init                    最初の設定をします
  add-employee [options]  社員を追加します。 -m, --multiple 複数の社員を追加します
  edit-employee           社員の名前を変更します
  remove-employee         社員を削除します
  create-team             スクラムチームを作成します
  list-team               スクラムチームのメンバーを表示します
  edit-team [options]     スクラムチームのPOかSMを変更します。-po, --product-owner | -sm, --scrum-master
  disband-team            スクラムチームを解散します
  add-developer           スクラムチームの開発者を追加します
  remove-developer        スクラムチームから開発者を除外します
  help [command]          display help for command

今回はスクラムチームの Read である list-team コマンドと Write である create-team コマンドを見ていきます。なお、CLI のライブラリには commander.js を採用しています。

データの Read

まず Read です。スクラムチームをコンソールに表示しましょう。コマンドの実行結果は以下の通りです。

$ yarn scrum list-team
$ node dist/index.js list-team
プロダクトオーナー: 丸山  茜
スクラムマスター: 渡辺 誠
開発者(3名): 河野 悠斗, 渡辺 誠, 竹内  太一

コードもかなりシンプルです。

import { ListScrumTeamQueryService, ListTeamDto } from '@panda-project/use-case'
import { Command } from 'commander'

class ListScrumTeamPresenter {
  exec(dto: ListTeamDto) {
    const { poName, smName, developerNames } = dto
    const developerBody =
      developerNames.length === 0
        ? '開発者はいません'
        : `開発者(${developerNames.length}名): ${developerNames.join(', ')}`
    return `プロダクトオーナー: ${poName}
スクラムマスター: ${smName}
${developerBody}`
  }
}

export const addListTeamCommand = (program: Command) => {
  program
    .command('list-team')
    .description('スクラムチームのメンバーを表示します')
    .action(async () => {
      try {
        const result = await new ListScrumTeamQueryService().exec()

        const output = new ListScrumTeamPresenter().exec(result)
        console.info(output)
      } catch (e: any) {
        console.error(e?.message)
      }
    })
}

ユースケース層の箇所で紹介したListScrumTeamQueryServiceを使ってデータ取得しています。そのデータを ListScrumTeamPresenter というクラスで CLI 用の表示の整形をしています。 あとは console.info で整形結果を出力しているだけです。

Web と比較すると、どちらも似たような構造を持っていることにお気づきでしょうか。すなわち、

  1. データを取得(それぞれの QueryService)
  2. データを整形(Presenter クラスか React + JSX か)
  3. データを表示(console.info か HTML か)

記述方法は異なっていても、構造としては同じなのです。それは、クリーンアーキテクチャ上どちらも Presentation 層に属しているからです。

データの Write

次に Write 面を見てみましょう。

コマンドの実行結果
POとSMは表示された選択肢から選ぶ形になっている

import { select } from '@inquirer/prompts'
import {
  CreateScrumTeamCliCommand,
  CreateScrumTeamQueryService,
  CreateScrumTeamQueryServiceDto,
  CreateScrumTeamQueryServiceInput,
  ScrumTeamUseCase,
} from '@panda-project/use-case'
import { Command } from 'commander'

type SelectProductOwner = (
  arg: CreateScrumTeamQueryServiceDto['candidateEmployees']
) => Promise<{ newProductOwnerId: number }>
type SelectScrumMaster = (
  arg: CreateScrumTeamQueryServiceDto['candidateEmployees']
) => Promise<{ newScrumMasterId: number }>

export const addCreateTeamCommand = (program: Command) => {
  program
    .command('create-team')
    .description('スクラムチームを作成します')
    .action(async () => {
      const selectProductOwner: SelectProductOwner = async (candidates) => {
        const newProductOwnerId = await select({
          message: 'プロダクトオーナーを選択してください',
          choices: candidates.map((v) => ({
            name: `${v.id}: ${v.name}`,
            value: v.id,
          })),
        })
        return { newProductOwnerId }
      }
      const selectScrumMaster: SelectScrumMaster = async (candidates) => {
        const newScrumMasterId = await select({
          message: 'スクラムマスターを選択してください',
          choices: candidates.map((v) => ({
            name: `${v.id}: ${v.name}`,
            value: v.id,
          })),
        })
        return { newScrumMasterId }
      }

      try {
        const { candidateEmployees: productOwnerCandidates } = await new CreateScrumTeamQueryService().exec()
        const { newProductOwnerId } = await selectProductOwner(productOwnerCandidates)

        const input = new CreateScrumTeamQueryServiceInput([newProductOwnerId])
        const { candidateEmployees: scrumMasterCandidates } = await new CreateScrumTeamQueryService().exec(input)
        const { newScrumMasterId } = await selectScrumMaster(scrumMasterCandidates)

        const command = new CreateScrumTeamCliCommand(newProductOwnerId, newScrumMasterId)
        await new ScrumTeamUseCase().create(command)
      } catch (e: any) {
        console.error(e?.message)
      }
    })
}

多くのクラスがあって少しややこしいですね。コマンドを実行した時の表示を見てもらえればなんとなく複雑さの理由が想像できるかもしれません。

なぜややこしいかというと、CLI の対話側インターフェースの特性に由来しています。チームを作るにあたり、まずプロダクトオーナーを選択します。次にスクラムマスターを選択するのですが、プロダクトオーナーとスクラムマスターは兼任できません。

このため、スクラムマスターの候補者からプロダクトオーナーを除外する必要があるのです。結果、CreateScrumTeamQueryService を2度呼び出しています。Web では select のフォームなので、最初にプロダクトオーナーとされた人を filter して、スクラムマスターの選択肢に表示しないという処理が可能です。CLI ではそれができないため、このような実装をしています。

最終的に CreateScrumTeamCliCommand を呼び出して、ScrumTeamUseCase().create(command)に command オブジェクトを渡して操作は終了です。

まとめ

いかがでしたでしょうか。もちろん、CLI で変更した内容は Web にも反映されますし、逆もまた然りです。Web や CLI という外側は変わっても、中身は同じロジック、同じ永続先を扱っているためです。これこそがクリーンアーキテクチャの威力であると言えるでしょう。

一方、実際の開発現場では様々な制約があり、このようなコード構成は中々実現できないはずです。しかし、変更に強い設計とは何か、その理想を知っていると現実でも最初から妥協せずに目の前のコードを少しでも良くしたいという思いが湧いてくるのではないでしょうか。

この記事を読んでくださった方が、より良い設計や実装を探求するきっかけになれば幸いです。

最後に、クリーンアーキテクチャの理解や Next.js の使い方に関して根本のところは合っているとは思うのですが、もし一般的なものと異なる内容であればぜひご指摘ください。

いつも議論に付き合ってくれる oga さん、 ganさん、zawa さん、DDD を実践してクリーンなコードを書いている wakanaさん、tiayo さん、頼りになるテックリードの kawashima さん、スクラムマスターの tanden さんたち同僚のおかげでこの内容が書けました。いつもありがとうございます。

参考

Discussion