3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DDD・CQRS・イベント駆動を組み合わせたバックエンドアーキテクチャ設計

3
Posted at

バックエンドのアーキテクチャを設計するとき、私が大切にしているのは、ビジネスロジックと技術的な実装をできるだけ分離することです。

アプリケーションの規模が小さいうちは、Controllerに処理を書き、データベースから取得した値を更新して返すだけでも十分に開発できます。

しかし、機能が増えていくにつれて、次のような問題が発生しやすくなります。

  • ビジネスロジックがControllerやデータアクセス処理に分散する
  • データベースや外部サービスの変更が、ビジネスロジックに影響する
  • 一つの操作に複数の処理が結び付き、変更が難しくなる
  • データの更新処理と参照処理が複雑になる
  • 他のシステムとの連携が増え、依存関係を把握しにくくなる

こうした問題をできるだけ避けるために、私はレイヤードアーキテクチャをベースとして、ヘキサゴナルアーキテクチャ、DDD、CQRS、イベント駆動の考え方を組み合わせた構成を採用しています。

この記事では、その構成と各レイヤーの役割、処理の流れについて整理します。

1. 全体のアーキテクチャ

基本となるのは、次の4つのレイヤーです。

  1. Presentation
  2. Application
  3. Domain
  4. Infrastructure

それぞれの責務を明確にし、ビジネスロジックが特定のフレームワークやデータベースに依存しない構造を目指します。

アーキテクチャ全体図

まず、全体の関係を図にすると次のようになります。

この構成で重要なのは、各レイヤーの責務を明確にすることです。

Presentation Layer

Presentation Layerは、アプリケーションの入出力を担当します。

例えば、HTTP APIであればControllerがこのレイヤーに該当します。

主な責務は次のとおりです。

  • リクエストの受け付け
  • 入力値の変換
  • 対応するユースケースの呼び出し
  • 実行結果のレスポンスへの変換

ここでは、ビジネスルールそのものを実装しません。

Controllerは、どのユースケースを実行するのかを判断し、その結果を外部に返すことに集中させます。

Application Layer

Application Layerは、アプリケーションが提供するユースケースを実現するレイヤーです。

例えば、次のような処理が該当します。

  • ユーザーのプロフィールを変更する
  • サークルを作成する
  • メンバーを追加する
  • 特定のサークル情報を取得する

このレイヤーでは、Domain Layerのオブジェクトを組み合わせて、ユースケース全体の処理を進めます。

更新系の処理はInteractor、参照系の処理はQueryServiceとして分けています。

Domain Layer

Domain Layerは、ビジネス上のルールや制約を表現するレイヤーです。

このレイヤーには、例えば次のようなものを配置します。

  • Entity
  • Value Object
  • Aggregate
  • Domain Event
  • Domain Service
  • RepositoryやFactoryのインターフェース

アプリケーションにとって重要なルールは、できるだけこのレイヤーに集約します。

データベースやメッセージングサービスなどの技術的な詳細を知らなくても、ドメインの振る舞いを表現できることを重視します。

Infrastructure Layer

Infrastructure Layerは、技術的な実装を担当します。

例えば、次のようなものを配置します。

  • Repositoryの実装
  • EventBus
  • EventStore
  • Factoryの実装
  • データアクセス処理
  • 外部メッセージングサービスとの連携

Domain LayerやApplication Layerで定義されたインターフェースを、このレイヤーで具体的に実装します。

これによって、ビジネスロジックと技術的な実装を分離できます。

2. ディレクトリ構成

私が基本としているディレクトリ構成は次のとおりです。

src/
├── domain/
│   ├── models/
│   └── services/
│
├── application/
│   ├── use-cases/
│   └── interactors/
│
├── infrastructure/
│   ├── event-bus/
│   ├── event-store/
│   ├── persistence/
│   ├── query-services/
│   └── messenger/
│
└── presentation/
    └── controllers/

ディレクトリ名だけで責務がある程度分かるようにすることで、機能追加やコードの調査がしやすくなります。

特に意識しているのは、ユースケースの定義と実装を分けることです。

Application Layerではユースケースのインターフェースを定義し、実際の処理はInteractorやQueryServiceに任せます。

3. Domain Layerの設計

Domain Event

Domain Eventは、ドメイン内で発生した重要な出来事を表現するオブジェクトです。

例えば、プロフィールの名前が変更された場合、単に名前を書き換えるだけではなく、「プロフィールの名前が変更された」という出来事を表現します。

TypeScriptでは、次のようなイメージです。

export class ProfileRenamed {
  constructor(
    public readonly profileId: string,
    public readonly newName: string,
    public readonly occurredAt: Date,
  ) {}
}

イベントには、何が起きたのかを判断するための情報を持たせます。

重要なのは、イベントが命令ではなく、すでに発生した事実を表現することです。

このイベントを利用することで、別の処理にドメインの変更を通知できます。

Event Subscriber

Event Subscriberは、発行されたイベントを受け取って処理するためのインターフェースです。

export interface EventSubscriber<TEvent> {
  handle(event: TEvent): Promise<void>;
}

例えば、プロフィール名が変更されたときに通知を送信したり、別のデータを更新したりする処理をSubscriberとして実装できます。

イベントを発行する側は、具体的にどのような処理が実行されるかを知る必要がありません。

Event Publisher

Event Publisherは、イベントをSubscriberに通知するためのインターフェースです。

export interface EventPublisher<TEvent> {
  publish(event: TEvent): Promise<void>;
}

実際のイベント通知処理はInfrastructure Layerに実装します。

これによって、Domain LayerやApplication Layerはイベント配信の仕組みに依存しなくなります。

Aggregate

Aggregateは、データの整合性を維持するための境界です。

関連するオブジェクトを一つのまとまりとして扱い、どの範囲で状態の変更を管理するのかを明確にします。

例えば、プロフィールの名前を変更する場合、Aggregate自身に変更処理を持たせます。

export class Profile {
  constructor(
    public readonly id: string,
    private name: string,
  ) {}

  rename(newName: string): ProfileRenamed {
    this.name = newName;

    return new ProfileRenamed(
      this.id,
      newName,
      new Date(),
    );
  }
}

ここで意識しているのは、外部から直接プロパティを書き換えるのではなく、Aggregateが提供する振る舞いを通して状態を変更することです。

また、変更が発生したときにはDomain Eventを返します。

これにより、どのような変更が発生したのかをApplication Layerで扱えるようになります。

Repository

Repositoryは、Aggregateの取得方法を隠蔽するためのインターフェースです。

export interface ProfileRepository {
  getById(id: string): Promise<Profile>;
}

Domain Layerではインターフェースだけを定義し、具体的なデータ取得処理はInfrastructure Layerで実装します。

これによって、ドメインモデルがデータベースの種類やORMに依存することを防げます。

Factory

Factoryは、Aggregateの生成処理を担当します。

export interface ProfileFactory {
  create(name: string): Promise<Profile>;
}

生成に必要な処理が複雑になった場合でも、その責務をAggregate自身やApplication Layerに集中させずに済みます。

4. Application LayerとCQRS

Application Layerでは、ユースケースをCommandとQueryに分けています。

これはCQRSの考え方を取り入れたものです。

  • Command:データや状態を変更する操作
  • Query:データを取得する参照操作

両者を同じ仕組みで処理するのではなく、それぞれの目的に合わせて実装します。

Use Case Input Port

まず、ユースケースのインターフェースを定義します。

export interface UseCase<Input, Output> {
  execute(input: Input): Promise<Output>;
}

具体的なユースケースごとにInputとOutputを定義します。

例えば、プロフィール名を変更する場合は次のようになります。

export interface RenameProfileInput {
  profileId: string;
  newName: string;
}

export interface RenameProfileOutput {}

このように入出力を明示することで、ユースケースの責務が分かりやすくなります。

Interactor

InteractorはCommandに該当するユースケースの実装クラスです。

Domain Layerのオブジェクトを利用しながら、ビジネスユースケースを実行します。

例えば、プロフィール名を変更する処理は次のようになります。

export class RenameProfileInteractor {
  constructor(
    private readonly repository: ProfileRepository,
    private readonly eventPublisher: EventPublisher<ProfileRenamed>,
  ) {}

  async execute(
    input: RenameProfileInput,
  ): Promise<void> {
    const profile = await this.repository.getById(
      input.profileId,
    );

    const event = profile.rename(input.newName);

    await this.eventPublisher.publish(event);
  }
}

Interactorの役割は、ユースケース全体の流れを制御することです。

具体的には、

  1. RepositoryからAggregateを取得する
  2. Aggregateの振る舞いを呼び出す
  3. Domain Eventを受け取る
  4. Event Publisherを通してイベントを発行する

という流れになります。

ビジネスルールそのものはAggregateに任せ、Interactorはそれらを組み合わせる役割に集中させます。

QueryService

一方、QueryServiceはQueryに該当するユースケースを実装します。

Queryでは、基本的にドメインの状態を変更しません。

そのため、Commandとは異なるデータ取得用のインターフェースを利用できます。

export interface ProfileDataAccess {
  getById(id: string): Promise<ProfileData>;
}

QueryServiceでは、このようなデータアクセスインターフェースを利用して必要なデータを取得します。

export class GetProfileQueryService {
  constructor(
    private readonly dataAccess: ProfileDataAccess,
  ) {}

  async execute(id: string): Promise<ProfileData> {
    return this.dataAccess.getById(id);
  }
}

参照処理をCommandから分離することで、それぞれのユースケースに必要な処理だけを持たせられます。

5. Infrastructure Layerとイベント駆動

Infrastructure Layerでは、Domain LayerやApplication Layerで定義したインターフェースを具体的に実装します。

ここでは、イベントを中心とした仕組みについて説明します。

EventBus

EventBusはEvent Publisherの実装です。

イベントを受け取り、登録されているSubscriberに通知します。

export class EventBus {
  constructor(
    private readonly subscribers: EventSubscriber<unknown>[],
  ) {}

  async publish(event: unknown): Promise<void> {
    await Promise.all(
      this.subscribers.map((subscriber) =>
        subscriber.handle(event),
      ),
    );
  }
}

EventBusを用意することで、イベントの発行側と受信側を疎結合にできます。

新しいイベント処理を追加したい場合も、既存のInteractorに直接処理を追加する必要がなくなります。

EventBusとSubscriberの関係

イベントを発行した後、EventBusは複数のSubscriberに通知できます。

例えば、プロフィール名が変更されたとき、EventStoreにイベントを保存すると同時に、外部サービスへ通知することもできます。

イベントの発行側は、それぞれのSubscriberの実装を知る必要がありません。

EventStore

EventStoreは、発行されたDomain Eventを永続化するための仕組みです。

Event Subscriberとして実装することで、EventBusから通知されたイベントを保存できます。

export class ProfileEventStore {
  private readonly events: ProfileRenamed[] = [];

  async handle(event: ProfileRenamed): Promise<void> {
    this.events.push(event);
  }
}

ここでは簡単なインメモリ実装にしていますが、実際にはデータベースなどを利用してイベントを永続化します。

Repositoryの実装

Event Sourcingを採用する場合、Aggregateの最新状態を直接保存するのではなく、発生したイベントを蓄積していきます。

Repositoryは、そのイベントを取得し、順番に適用することでAggregateの状態を復元します。

例えば、次のようなイベントが保存されているとします。

1. ProfileCreated
2. ProfileRenamed
3. ProfileEmailChanged
4. ProfileRenamed

これらのイベントを発生順に適用することで、現在のプロフィールの状態を復元できます。

この方式では、イベントの履歴が状態変更の記録になります。

そのため、Aggregateの最新状態を保存するための処理をユースケースに明示的に書く必要がありません。

Messenger

Messengerは、Domain Eventを外部のメッセージングサービスに通知するための実装です。

例えば、イベントを受け取った後にMQやKafkaなどへメッセージを送信します。

外部サービスとの連携をSubscriberとして切り出しておけば、ドメインの処理と通信処理を分離できます。

QueryServiceのデータアクセス

QueryServiceが参照するデータについては、Infrastructure Layerにデータアクセス処理を用意します。

Command側のAggregateを取得するRepositoryとは別に、参照に適したデータ構造を利用できるようにします。

CQRSを採用することで、更新処理と参照処理をそれぞれの目的に合わせて設計できます。

6. アプリケーション全体の処理フロー

ここまでの構成を踏まえて、実際の処理の流れを整理します。

Commandの場合

例えば、プロフィールの名前を変更する場合です。

処理の流れは次のとおりです。

  1. Controllerがリクエストを受け取る
  2. 対応するCommandのUse Caseを呼び出す
  3. InteractorがRepositoryを通してAggregateを取得する
  4. Aggregateの振る舞いを呼び出して状態を変更する
  5. AggregateからDomain Eventを受け取る
  6. Event Publisherを通してイベントを発行する
  7. EventBusが登録されたSubscriberにイベントを通知する
  8. EventStoreなどがイベントを処理する
  9. Controllerがレスポンスを返す

この構成では、ControllerやInteractorがイベントの保存先や通知先を直接知る必要がありません。

Queryの場合

次に、プロフィール情報を取得する場合です。

処理の流れはシンプルです。

  1. Controllerがリクエストを受け取る
  2. 対応するQueryのUse Caseを呼び出す
  3. QueryServiceがデータアクセス処理を実行する
  4. 必要なデータを取得する
  5. Controllerがレスポンスを返す

CommandとQueryを分けることで、更新と参照の責務を明確にできます。

7. フロントエンドには同じ構成を適用しない

ここまでバックエンドの構成について説明しましたが、フロントエンドについては必ずしも同じアーキテクチャを採用する必要はないと考えています。

特に、バックエンドがマイクロサービス化されている場合、フロントエンドはプロダクト全体のプレゼンテーションを担当する役割になることが多くなります。

その場合、複雑なビジネスロジックはバックエンド側に集約され、フロントエンドは画面表示やユーザー操作、APIとの連携を中心に担当します。

そのため、バックエンドと同じようにDomain、Application、Infrastructureというレイヤーを細かく分割することが、必ずしも適切とは限りません。

フロントエンドでは、ReactやVueなどのフレームワークが提供する設計プラクティスを優先し、必要な範囲で責務を分離する方針にしています。

8. このアーキテクチャで重視していること

今回紹介した構成で、特に重視しているのは次の3点です。

1. ビジネスロジックを技術的な詳細から分離する

ビジネスルールがデータベースやフレームワークに依存してしまうと、技術的な変更がビジネスロジックにまで影響します。

Domain Layerを中心に設計することで、ビジネス上のルールを独立して管理しやすくなります。

2. CommandとQueryの責務を分ける

データを変更する処理と参照する処理では、求められる責務が異なります。

CQRSの考え方を取り入れることで、それぞれの処理をシンプルに保ちやすくなります。

3. イベントを通して処理同士を疎結合にする

一つの操作をきっかけに、複数の処理を実行したいケースがあります。

そのような場合、イベントを介して処理を分離しておけば、既存のユースケースを大きく変更せずに機能を追加できます。

まとめ

今回は、私がバックエンドの設計で採用しているアーキテクチャの考え方を紹介しました。

基本的には、レイヤードアーキテクチャをベースに、ヘキサゴナルアーキテクチャ、DDD、CQRS、イベント駆動、Event Sourcingを組み合わせています。

それぞれの技術や設計パターンを採用すること自体が目的ではありません。

大切なのは、ビジネスロジックをどこに置くのか、どの処理がどの責務を持つのか、そして変更が発生したときにどこまで影響するのかを明確にすることです。

もちろん、すべてのプロジェクトにこの構成が必要なわけではありません。

小規模なアプリケーションであれば、もっとシンプルな構成の方が適している場合もあります。

一方で、ビジネスルールが複雑になり、外部システムとの連携や機能追加が増えていくようなバックエンドでは、このように責務を分離した構成が役立つと考えています。

プロジェクトの規模や性質に合わせて、必要な設計パターンを選択していくことが重要です。

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?