実践! Typescript で DDD - マイクロサービス設計のすすめ

f:id:k-yoshimura-lvgs:20210819141006p:plain

対象読者

  • マイクロサービス化を検討しており、実際に作る場合の構成を参考にしたい。
  • ドメイン駆動設計について、基本的な用語の知識がある。
  • TypeScript を多少触ったことがある。理解がある。

はじめに

こんにちは。エンジニアの吉村です。 現在、弊社が運営する teratail というサービスに携わっており、CakePHP で動作しているモノリシックな既存サービスをマイクロサービスに移行するというプロジェクトを進行中です。

この記事では、実務を通して得た知見として、マイクロサービス化によりどんな恩恵があるのか、具体的にどのような構成で実装をしているのかについてご紹介します。

TL;DR

マイクロサービスのバックエンドサービスの実装に焦点を絞って、ドメイン駆動設計 + オニオンアーキテクチャをベースに設計をしました。 本記事では、具体的に「ユーザ新規登録処理」の実装をする場合を例にとり、実装手順をなぞりながら詳細を解説しています。 結論としては、従来の MVC ベースのアーキテクチャと比較して、メンテナンス性が高く技術的負債が生じづらい、スケーリングが容易な構成にできたのではないかと思います。その反面、簡単な処理の実装であっても記述量が多くなってしまうため、初期コストは割と高めな構成であるように思いました。

なぜマイクロサービス化を導入したのか

f:id:k-yoshimura-lvgs:20210819110213p:plain

モノリシックサービスとマイクロサービスとで、どちらの構成が優れているかについては、実装するサービスの規模や様々な背景などによって左右される部分ではありますが、 今回のケースでは

  • 比較的規模の大きいシステムであること
  • サービスの改善を更に速度を上げて実施すべく、スケールしやすい構成にしておく必要があること
  • 弊社の他マイクロサービスと相互連携をしやすい構成にする必要があったこと

などの理由から、マイクロサービスとしてリプレースをしていく方針となりました。

マイクロサービスでシステムを実装をしていくにあたって、初期設計の段階で失敗してしまうとメンテナンスが困難なシステムができあがってしまう恐れがあるため、アーキテクチャの検討は注意深く行っていく必要がありました。 様々な文献で構成を調べる上で、特に気をつけたポイントは、なるべく技術的負債を回避でき、処理の把握が容易でメンテナンス性が高く、ミドルウェア等の環境の変更に強いシステムとなることでした。

それら検討の上で現在どのような構成で実装をしているか、簡単な処理での例を基にご紹介します。

サービスの構成

サービス全体の構成としては、フロントエンドサーバ、BFFサーバ、バックエンドサーバ と分けて実装をしていますが、 今回はバックエンドサーバのサービス構成に焦点を絞ってご紹介していきます。

構成図

サービスの構成は、オニオンアーキテクチャ[1]を参考に、以下の図の構成としています。

f:id:k-yoshimura-lvgs:20210819110416p:plain

サービスの根幹となるドメイン層を中心に置いて、アプリケーション層、アダプタ層、インフラストラクチャ層と順に囲っていき、 外側の層から内側の層への単方向の依存のみ許可しています。※図の右側の各モジュールの詳細については後述します。

マイクロサービス化をするにあたり、ドメイン駆動設計を参考に、一つのアプリケーションを、例えばユーザ のアカウントについて管理する user サービスや、投稿された内容を管理する post サービスなど、それぞれ独立して成立するサービスとして分割していきます。このときに分割されたサービスの粒度を1単位としてサブドメインと呼ぶことにし、サブドメインごとに上図の構成のサービスを実装していきます。

ディレクトリ構成

ディレクトリの基本的な構成は以下の通りです。 以下の例ではサブドメインが user のみの構成となっていますが、サブドメインの数だけここに同様の構成のものが並びます。 src/shared には共通のコアロジックや、ベースクラスなどを配置しています。

src
├── server.ts
├── subdomains
│   └── user
│       ├── Entities
│       │   └── User.ts
│       ├── Events
│       │   └── UserCreated.ts
│       ├── Infrastructures
│       │   └── typeorm
│       │       └── User.ts
│       ├── Mappers
│       │   └── UserMap.ts
│       ├── Repositories
│       │   ├── UserRepository.ts
│       │   └── implements
│       │       └── TypeOrmUserRepository.ts
│       ├── Subscriptions
│       │   └── AfterUserCreated.ts
│       ├── UseCases
│       │   └── CreateUser
│       │       ├── CreateUserUseCase.ts
│       │       ├── CreateUserDTO.ts
│       │       ├── CreateUserErrors.ts
│       │       └── CreateUserController.ts
│       └── ValueObjects
│           └── UserName.ts
└── shared
    ├── core
    │   ├── AppError.ts
    │   ├── Guard.ts
    │   ├── Result.ts
    │   ├── UseCase.ts
    │   └── UseCaseError.ts
    └── domain
        ├── events
        │   ├── DomainEvents.ts
        │   ├── IDomainEvent.ts
        │   └── IHandle.ts
        ├── AggregateRoot.ts
        ├── Entity.ts
        ├── Identifier.ts
        ├── UniqueEntityID.ts
        ├── ValueObject.ts
        └── WatchedList.ts

実際に「ユーザ登録処理」を作ってみる

ドメイン層の定義

まず、ドメイン層として ValueObject と Entity の定義をしていきます。 ドメイン層はサービスの根幹となる部分であり、すべての処理はこのドメイン層で定義されたものを操作する形で実装していきます。

ValueObject

ValueObject は、Entityを構成するプロパティとして使用したり、レイヤ間のやり取りをする箇所などで用います。 ValueObject を用いるメリットとしては、対象の値が正しい形であるということが担保できるという点と、その値に対してのビジネスルールを豊かに表現できるという点があります。 オブジェクトを生成する際に必ずバリデーションロジックを通すように作ることができるため、ValueObject としてデータが存在している時点で、形式的に正しい値を持っているということが保証され、様々な箇所で形式チェックをする必要がありません。 また、例えば Email という ValueObject を作成したときに、対象の文字列からドメイン部分のみ抽出するというメソッドを用意したりなど、対象の値に対して何らかの加工をした形で取得をするといったことをまとめて定義しておくことができます。

以下の例では、ユーザの名前を表現する UserName ValueObject を定義しています。 入力された値が 3 文字以上 32 文字以下の場合はエラーを返す、簡単なバリデーションロジックを組み込んでいます。

// src/subdomains/user/ValueObjects/UserName.ts
export interface UserNameProps {
  value: string
}

export class UserName extends ValueObject<UserNameProps> {
  get value(): string {
    return this.props.value
  }

  private constructor(props: UserNameProps) {
    super(props)
  }

  private static isValidName(name: string) {
    const re = /^.{3,32}$/
    return re.test(name)
  }

  public static create(name: string): Result<UserName> {
    if (!this.isValidName(name)) {
      return Result.fail<UserName>(`Invalid Argument - userName:[${name}]`)
    }
    return Result.ok<UserName>(
      new UserName({ value: name })
    )
  }
}

※ Result

Result クラスは、成功形か失敗形かの状態を持たせた結果データを表すものとして実装しています。 成功形の場合は成功した値を取得することができ、失敗形の場合はエラーレスポンスを取得できます。どちらの状態であるかは isSucceedisFailure のプロパティの値で判別可能です。 上記 ValueObject の生成メソッドでこの戻り値を利用していますが、状態ごとの処理を記述する可能性のあるあらゆる箇所で利用しています。

Entity

用意した ValueObject をプロパティとして設定し、Entity を定義します。 Entity は固有の識別子として id を持っており、 id が一致している Entity は、プロパティが異なっている場合でも同一のものとして扱います。

// src/subdomains/user/Entities/User.ts
interface UserProps {
  name: UserName
  createdAt: DateTime
  updatedAt: DateTime
  deletedAt?: DateTime
}

export class User extends Entity<UserProps> {
  get name(): UserName {
    return this.props.name
  }
  
  get createdAt(): DateTime {
    return this.props.createdAt
  }

  get updatedAt(): DateTime {
    return this.props.updatedAt
  }

  get deletedAt(): DateTime | undefined {
    return this.props.deletedAt
  }

  private constructor(props: UserProps, id?: UniqueEntityID) {
    super(props, id)
  }

  public static create(props: UserProps, id?: UniqueEntityID): Result<User> {
    const user = new User({...props}, id)
    return Result.ok<User>(user)
  }
}

アプリケーション層の実装

次に、定義されたドメイン層の部品を操作して、ユーザ作成処理を記述するアプリケーション層の UseCase、 DTO、 RepositoryInterface を実装していきます。

DTO

まず、 DTO(Data Transfer Object)を定義します。 DTO は、UseCase の入出力パラメータを表します。 UseCase がリクエストとしてどういう形式を求めているか、レスポンスとしてどんなデータが返るのかを明確に定義しておくことで UseCase の仕様を把握しやすくするなる他、UseCase を実行する際にはこの定義に従って呼ぶようにすればよいため、 通信プロトコルが何なのか、または DomainEvent (後述します)の後続処理として実行されるのかなど、実行元が何であるかなどを意識することなく UseCase を定義することができるようになります。

// src/subdomains/user/UseCases/CreateUser/CreateUserDTO.ts
export interface CreateUserRequestDTO {
  name: string
}

export interface CreateUserResponseDTO {
  success: boolean
}

UseCase

UseCase は、ドメイン層を操作してデータの加工をし、Repository にデータを渡す処理や、Repository から取得したデータを返却する処理を記述していきます。 UseCase は、できるだけ単一の操作にのみ責任を持つように作るほうが良いです。例えば、エンティティを新規作成する UseCase を作成する場合は、データ作成の成否のみを(必要であれば作成したエンティティのIDのみあわせて)返却するよう実装し、エンティティ全体のデータを取得する UseCase は別途作成するようにしておきます。 このように、データの操作のみに責任をもつ Command 処理と、データの返却にのみ責任を持つ(データを操作しない) Query 処理に明確に分けて実装しておくと、それぞれの処理の最適化に注力しやすくなり、データの読み出しに関してはデータに副作用を与えないことが担保された Query 処理を使うことで、安全にシステムを利用することができるようになります。※CQRS(コマンドクエリ責務分離)[2]

以下の例では、ユーザデータを作成を実行して、成功か失敗かの bool 値のみを返す Command UseCase を実装しています。

// src/subdomains/user/UesCase/CreateUser/CreateUserUseCase.ts
type Response = Either<
  | CreateUserAlreadyExistsError
  | CreateUserFailedToCreateUserError
  | UnexpectedError
  | BadRequestError,
  Result<CreateUserResponseDTO>
>

export class CreateUserUseCase
  implements UseCase<CreateUserRequestDTO, Promise<Response>> {
  constructor(
    private userRepository: UserRepository
  ) {}

  async execute(req: CreateUserRequestDTO): Promise<Response> {
    const nameOrError = UserName.create(req.name)
    if (nameOrError.isFailure) {
      return failed(new BadRequestError(nameOrError.errorValue().value))
    }

    const name = nameOrError.getValue()
    const now = DateTime.utc()
    try {
      if (await this.userRepository.isExists(name)) {
        return failed(new CreateUserAlreadyExistsError(name))
      }

      const userOrError = User.create({
        name: name,
        createdAt: now,
        updatedAt: now
      })
      if (userOrError.isFailure) {
        return failed(new CreateUserFailedToCreateUserError())
      }

      const user = userOrError.getValue()
      await this.userRepository.save(user)

      return succeed(
        Result.ok<UserCreateResponse>({
          success: true,
        })
      )
    } catch (err) {
      return failed(new UnexpectedError(err.message))
    }
  }
}

※ Either

UseCase のレスポンスは、Either クラスで表現しています。 UseCase の結果値として、複数の Result オブジェクトが返る場合がありますが、それぞれが正常系か異常系かを振り分けて定義しています。 これにより、 UseCase の利用側はレスポンスを判別が成功系か異常系か判別して処理を記述しやすくなるほか、UseCase の定義書のように機能し、どんなパターンのレスポンスが存在するか一目で分かるようになる利点があります。

UseCaseError

UseCase の異常系レスポンスとして、UseCaseError を定義します。

// src/subdomains/user/UesCase/CreateUser/CreateUserError.ts
export class CreateUserAlreadyExistsError extends Result<UseCaseError> {
  constructor(name: UserName) {
    super(false, {
      message: `User name already exists. - name: ${name.value}`
    } as UseCaseError)
  }
}

export class CreateUserFailedToCreateUserError extends Result<UseCaseError> {
  constructor() {
    super(false, {
      message: "Failed to create user."
    } as UseCaseError)
  }
}

RepositoryInterface

UseCase 実装時点で、Repository にアクセスする必要がある箇所を記述する場合は、Repository の実装を抽象化した RepositoryInterface を作成し、抽象化された Repository へアクセスする形で実装していきます。※依存性逆転の原則[3]

これをする理由は、UseCase は本来、データを永続化する仕組みが何であるか、ORMとして何を使っているのかなどは意識する必要がなく、永続化データへの読み書きさえできれば Repository の具体的な実装を気にしなくても良いためです。 また、Repository を抽象化せずに具体実装に対してアクセスするようにしてしまうと、アプリケーション層がそれより外側のアダプタ層に依存する形での実装となってしまい、内側の層が外側の層に依存してはならないというルールに反してしまうため、これを逆転させる目的があります。

// src/subdomains/user/Repositories/UserRepository.ts
export interface UserRepository {
  isExists(name: UserName): Promise<boolean>
  save(user: User): Promise<void>
}

データ永続化処理実装

UseCase 実装の際に定義した RepositoryInterface の定義に基づき、DB 定義とデータ永続化の具体実装をしていきます。 従来型の MVC による実装などに慣れていると、まず DB の定義をしてから UseCase 処理の実装をしていきたくなるかもしれませんが、今回のアーキテクチャではそれとは逆で、ドメイン層とアプリケーション層を実装したあとに DB 定義を行います。 Repository は アプリケーション層・ドメイン層の処理の中で生成・更新された Entity を永続化し、永続化されたデータを Entity として返すことにのみ責任を持つように実装していきます。 こうすることでサービスのコアとなるロジックが DB の定義や ORM などに密接に依存してしまうような作りを回避することができます。

DBスキーマ定義

TypeORM を使用する場合を例に以下のようにモデルを定義します。

// src/subdomains/user/Infrastructures/typeorm/User.ts
import {
  Column,
  CreateDateColumn,
  DeleteDateColumn,
  Entity,
  PrimaryGeneratedColumn,
  UpdateDateColumn
} from "typeorm";

@Entity()
export class User {

  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @CreateDateColumn({
    type: "datetime",
  })
  createdAt!: Date

  @UpdateDateColumn({
    type: "datetime",
  })
  updatedAt!: Date

  @DeleteDateColumn({
    type: "datetime",
    nullable: true,
  })
  deletedAt?: Date
}

Repository 実装

// src/subdomains/user/Repositories/implements/TypeOrmUserRepository.ts
import { createConnection, getRepository } from 'typeorm'
import { User } from '../../Infrastructures/typeorm/User'
import { UserMap } from '../../Mappers/UserMap'

export class TypeOrmUserRepository implements UserRepository {
  async isExists(name: UserName): Promise<boolean> {
    const connection = await createConnection()
    const userRepository = getRepository(User)

    const user = await userRepository.findOne({ name })

    await connection.close()

    return !!user
  }

  async save(user: User): Promise<void> {
    const connection = await createConnection()
    const userRepository = getRepository(User)

    await userRepository.save(UserMap.toPersistent(user))

    await connection.close()
  }
}

Mapper 実装

Entity <=> 永続化データ間の変換処理を Mapper として実装します。

// src/subdomains/user/Mappers/UserMap.ts
export interface UserRawProps {
  id: string
  name: string
  createdAt: Date
  updatedAt: Date
  deletedAt?: Date
}

export class UserMap implements Mapper<User> {
  public static toDomain(raw: UserRawProps): User {
    const userNameOrError = UserName.create(new UniqueEntityID(raw.name))
    if (userNameOrError.isFailure) {
      throw new Error(userNameOrError.error)
    }

    const userOrError = User.create(
      {
        name: userNameOrError.getValue(),
        createdAt: DateTime.fromJSDate(raw.createdAt),
        updatedAt: DateTime.fromJSDate(raw.updatedAt),
        deletedAt: raw.deletedAt
          ? DateTime.fromJSDate(raw.deletedAt)
          : undefined
      },
      new UniqueEntityID(raw.id)
    )

    if (userOrError.isFailure) {
      throw new Error(userOrError.error)
    }

    return userOrError.getValue()
  }

  public static toPersistent(userEntity: User): UserCreationParam {
    return {
      id: userEntity.id.toString(),
      name: userEntity.name.value,
      createdAt: userEntity.createdAt.toJSDate(),
      updatedAt: userEntity.updatedAt.toJSDate(),
      deletedAt: userEntity.deletedAt?.toJSDate()
    }
  }
}

通信インターフェース実装

ここまででユーザ登録処理の UseCase の実装が完了しました。次にこれを呼び出すインターフェースの実装をしていきます。 ここでは gRPC で実装する場合を例に実装していきます。

protocol buffer 定義

syntax = "proto3";
package sample.user.command_user;

service CommandUser {
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
}

message CreateUserRequest {
  string name = 1;
}

message CreateUserResponse{
  bool success = 1;
}

Controller 実装

// src/subdomains/user/UesCase/CreateUser/CreateUserController.ts
export class CreateUserController {
  constructor(
    private createUserUseCase: CreateUserUseCase
  )

  async createUser(
    req: CreateUserRequest,
    metadata?: Metadata
  ): Promise<CreateUserResponse> {
    const reqDTO = req as CreateUserRequestDTO
    const res = await this.createUserUseCase.execute(reqDTO)

    if (res.isFailed()) {
      const error = res.value
      switch (error.constructor) {
        case CreateUserAlreadyExistsError:
        case CreateUserFailedToCreateUserError:
        case UnexpectedError:
        case BadRequestError:
        default:
          return {
            success: false
          }
      }
    }

    return {
      success: true
    }
  }
}

これでユーザ登録処理の実装が完了しました。 gRPC で対象のインターフェースを呼び出すことで、DB に対象ユーザのデータが登録ができていることを確認することができます。

ユーザ作成直後に処理を実行したい場合

ユーザ登録処理が完了した直後、メールなどの通知を送信したりレポートデータを書き込みたいなどの後続処理を実装する必要が出てくるケースがあります。その場合は、既存の UseCase (今回でいうと CreateUserUseCase )に手を加えて実装はせず、別の UseCase として各処理を実装し、 DomainEvent を利用して処理を実行するのが望ましいです。

f:id:k-yoshimura-lvgs:20210819110501p:plain

① CreateUserUseCase を実行 ② User エンティティを生成したときに UserCreated イベントを発行。データの永続化が完了した時点でイベントを発火させる。 ③ Subscription がイベントの発火を検知して、後続処理を実行する。

DomainEvent

User エンティティが生成される際に、対象の Entity 情報を EntityID でタグ付けした DomainEvent を発行します。 この時点では、 DomainEvent を発行しているだけで、まだ発火はしていません。

// src/subdomains/user/Entities/User.ts
- export class User extends Entity<UserProps> {
+ export class User extends AggregateRoot<UserProps> { 

    // ...

    public static create(props: UserProps, id?: UniqueEntityID): Result<User> {
      const user = new User({...props}, id)

+     // 新規作成時のみイベントを発行するため、 id が付与されていない場合に実行
+     if (!id) {
+       this.addDomainEvent(new UserCreated(user))
+     }

      return Result.ok<User>(user)
    }
  }

イベントの発火は、データの永続化の完了を検知したタイミングで実行します。 TypeORM の場合を例にとると、 Entity Listener を使って以下のように実装します。

// src/subdomains/user/Infrastructures/typeorm/User.ts
@Entity()
export class User {

  // ...

+ @AfterInsert()
+ dispatchAggregateEvents() {
+   const aggregateId = new UniqueEntityID(this.id);
+   DomainEvents.dispatchEventsForAggregate(aggregateId);
+ }
}

Subscription は、 DomainEvent の発火を検知して、DomainEvent 内の Entity 情報を使って後続処理を実行するように実装しておきます。その上で、 Subscription をサーバ起動時に常時監視状態にしておくことで、ユーザ登録完了後に後続処理を実行する実装の完了です。

おわりに

マイクロサービスのバックエンドサービスの実装に焦点を絞って、オニオンアーキテクチャをベースに設計をしました。 実際にこの構成を採用してシステムを実装した所感として、従来の MVC アーキテクチャなどの実装と比較して良かった点、気になる点をまとめると以下のとおりです。

良かった点

  • サービスのコアロジックを独立して実装できるため、ミドルウェアの変更など環境の変化に柔軟に対応可能。
  • 各実装レイヤーごとに明確に責務を割り振ることで、処理の流れを把握しやすい。
  • 一貫したルールでの実装を半ば強制することで、システムの規模が大きくなるにつれて処理が複雑になりすぎるということが起きづらい。
  • 機能の追加・削除が容易で、スケールしやすい。

気になる点

  • 簡単な機能であってもコードの記述量が多くなってしまうため、初期コストが高い。

冒頭で述べたように、運用面でのメリットは多くありますが、初期の実装にかかるコストは高いように思いました。 リリースまでのリソースに余裕があり、長期的に運用をしていく可能性のあるシステムには導入する価値があるように思いますが、スタートアップのような限られたリソースでとにかく早くリリースをしたいという場合は、慎重に採用の判断をしたほうが良い構成であると思います。

各項目の解説については詳細を割愛している部分も多々ありますが、全体像の大まかなイメージだけでも伝われば幸いです。

参考記事