44
31

More than 1 year has passed since last update.

課題解決指向型ドメイン駆動設計 〜導入編〜

Last updated at Posted at 2022-07-19

筆者紹介

株式会社ビットキーで開発をしています (twitterはこちら)。フロントもバックエンドも開発しますが、バックエンド開発が好きで、Typescriptをよく利用します。物事を抽象的にとらまえ構造化して設計/実装するのが好物です。

はじめに

機会があり、ドメイン駆動設計(以後DDD)の勉強会を開催することになりました。(7/20(水)に 「DDD勉強会」 を実施します! → 実施しました!資料はこちら)
勉強会の内容に補足を加えて、Qiitaの記事として公開しています。本記事では全3部構成(予定)の導入部分について記載し、導入編 / 実践編 / 応用編 の3部構成を予定しています。内容自体は 軽量DDD / ボトムアップ型DDD に近しいかと思います。

第2部の実践編 & 7/20に予定している勉強会で詳細な実装に触れる予定です !

※ 無事に第1回目の勉強会を開催することができました!2回目は8/24です。1回目の勉強会で使用した資料はこちらにアップしています。

概要

私がビットキーというスタートアップ3年目の会社で「暮らし」をターゲットにしたhomehubというプロダクト開発において、DDDの考えを用いてサーバーサイドのリファクタリングをした経験をもとに、実際に行ったことや考えたことをまとめています。特に課題解決のために、DDDの利用可能な部分を導入するといった方法で上手くいったので、何を考え何をしたかと対応すすめる結果で学んだことを記載します。

言語はTypescriptを用いて記載しますが、できるだけ言語依存でない表現方法を心がけています。少しでも他のエンジニアの方に意味のあるものになれば幸いです。

勉強会 にもぜひご興味がある方は参加いただけると嬉しいです!!!!

1. DDDって言われても...

DDDって興味あるけど、導入するとなるとそうすればよいのだろう...って思ったことはないでしょうか?

私自身DDDを認知した2020年1月時点では導入のイメージは湧きませんでした...。今思うとDDDを用いて何を解決したいかが定まっていなかったからかと思います...。本記事では私が実際に抱えた課題に対してDDDを用いてどのように解決したかを記載します。

2. DDDとしてどうあるべきかは重要ではない

いきなりですが、導入時点においてDDDとしてどうあるべきかを追求はしなくてもよいと考えます(運用できてて、より洗練させたい場合などはその限りではないと思います。)。DDDも課題を解決するための1つの手段であり、課題を解決することが重要だからです。

DDDを導入しようとするときに「DDDとしてどうあるべきか」から入ると、なかなか実際に手をつけることが難しく導入が進まないのではと思います。実際に私もそうでした...。

...じゃぁどうすればいいの...?
ってことなのですが、立ち返って以下の2つを考えるのが良いと思います。

  • なんのために...? (解決したい課題がなんなのか)
  • どうやって解決する...? (課題の解決のために妥当な方法)

2-a. なんのために...?

解決したい課題を明確にすることが何よりも先決と考えます。せっかく頑張って導入しても達成したいことが不明確だとやってみたけど良くなりませんでした、ってことになりかねないので...

参考までに開発プロダクトが抱えていた課題は以下です。

  • 価値提供対象とする事業領域が非常に広く、機能の抽象化 / 構造化 / 共通化が推奨される
  • プロダクト数 / 機能数 が増えてきて、チームも大きくなってきた。結果、全体を把握しながら開発をすることが非常に困難な状態になった
  • 他機能で不整合となるデータを作る実装が可能な状態となってしまっている
  • 影響範囲の把握困難で、特定データ利用箇所チェックが必要 & つらい...
  • 調査時などデータから現状のステータス把握が困難 (データ遷移がぱっとわからないため)
  • データ修正時に必要な箇所の洗い出しが困難 (機能 × データの関連がぱっとわからないため)
  • validation等のロジックが散乱して機能修正が大変…
  • 登録値の認識の仕方が各機能差異があり機能修正時につらい… (AとBとで使い方異なってたりすると...)
  • メソッドを潜った先にデータ更新することが可能となってしまっている ..(service層→service層→...→service層の中で update & commit
    とか...)

プロダクトやチームが拡張してきて、暗黙的な決まりごとに対応しきないことや、全体をくまなく理解していなくても、安全に開発できる仕組みが必要となっていることが明確になりました。

2-b. どうやって解決する...?

課題が明確になったら、次にどのように解決するか考えます。すべてを解決できなくてもOKですし、そもそもDDDに固執する必要もありません。課題を整理した結果、最善の解決方法がDDDでないなら異なる方法をとるべきです。

実際に私は課題解決にあたって、ドメインオブジェクトの以下特性に着目しました
・ 堅牢性。仕組みとして不整合なデータの更新が抑制することを可能。
・ 知識の集約。仕様や挙動の制御などをドメインオブジェクトに集約して、理解しやすい状態をつくることが可能。

チーム全員がSOLID原則などを適切に実践できれば解決できるという話もあると思いますが、現実問題難しく、仕組みとしてメンバーの実装力に依存せずに実現できる方法が重要と考えています。マイクロサービスという選択肢もありますが、境界をしっかり引ききることが難しい領域が多数あり、DDDに着目しています。決済や認証など責務を明確にしやすい領域はマイクロサービス化しています。

※ 具体的にどのような順序で何をしたかは 実践編 もしくは 勉強会で共有します!

3.Before vs After

動作検証したものでないので、一部不整合な部分あるかもしれませんが、イメージを具体化できるようにサンプルソースを記載しています。機能としては、Queueにタスクを積んで1個づつ実行する機能をサンプル機能としています。1つめのソースがcontrollerレイヤの処理で、その中で1-3の番号を振っており、番号を振った処理に関して詳細を別途記載しています。

Beforeの状態のソースに、どのような課題があるのかを見ていきます。実際のソースそのままではないですが、実際にあった課題の部分はできるだけそのままの構成となるようなサンプルにしています。

3-a. Beforeの全体処理

処理実施すべきTaskや次に実施すべきTaskを特定してする処理で、全体の部分です(ControllerとかUsecase相当)。いくつかピックした処理は①、②、③と番号とつけて詳細を追加で記載しています。

スクリーンショット 2022-07-19 14.45.15.png

めちゃくちゃ分かりづらいとかではないと思うのですが、気になるポイントは以下

Check Point

  • 最後にエラーをcatchしたら失敗としてステータスを更新しているが、成功の場合はどこで更新しているの...?
  • switch分内でtypeごとに分岐して処理を実施しているがその中で、Taskの除外処理をしていること。分岐した処理内で実施されると挙動が把握しづらい...
    (まだcontrollerレイヤで実施しているだけまし)
  • setCurrentTaskが何しているかがちょっとわかりづらい...

① 処理対象のTaskをセットする処理

次に実施するべきタスクを特定して更新する処理。(次のTaskなのにsetCurrent?ってツッコミもありますが..)

スクリーンショット 2022-07-19 1.34.53.png
Check Point

  • 3つのデータを更新していて、それらの整合性をどうとるべきのかわかりづらい
  • データの状態から状況を確認しようとすると、更新する場所が複数あって整合性を整理するのに時間を要する
  • 機能追加や修正時に容易に不整合なデータを作り得てしまう
    (この状況はきつい...)

② validation処理

Queueに積まれたTaskの内容のチェック処理(のようにみえる。メソッド名を信じるなら..)
スクリーンショット 2022-07-19 1.39.35.png
Check Point

  • validationなのに、めっちゃ他のことしている...!!!!!!!
    (これはきつい...。これがあると await している部分の処理全部みないと変なデータ作ってないかわからない...)
  • 柔軟にデータ更新の処理が記載できてしまう & 明確なルールがないとこ、個別の機能追加を積み重ねた結果として、このような状況に陥りがち...

③ IoTデバイスにコマンドを配信する処理

Queueに積まれたTaskに応じた処理を実施する部分。Queue上でのステータス管理は行われないことが想起されますが....
スクリーンショット 2022-07-19 2.06.13.png
Check Point

  • Taskのステータス更新を行っている...
    本クラスの責務は、IoTデバイスにコマンドを配信する処理に特化したものが期待されますが、ステータス管理も行っており単一責務の原則に反してしまっています。また全体としてステータス管理がどのようにされているかを把握することが難しくなってしまっています。

3-b. 課題まとめ

  • 各処理内で(参照先のメソッド内で)データの更新がされており、処理全体の整合性がわかりづらい
  • 複数のファイル内で、暗黙のデータ整合性をよろしく担保するように実装がされていて、状況の把握や機能追加/修正が難しい

4. After

実際にQueueの仕組みをDDDを用いて実装したサンプルを記載します(こっちはあまり注釈つけなかったので、ソースをそのまま記載しています)。モデル図とか各Entityの責務、実装等は割愛してざっくり修正後のイメージを持っていただければと思っています。

4-a. Afterの全体処理

/**
 * Queueの処理を実行するUsecase
 * Queueの処理対象が指定されていない状態の場合に
 * 次の処理対象を特定して処理を実施する
 */
export class ExecuteNextQueueTaskUsecase {
  private context: TemporaryContext;
  private dep: ExecuteQueueTaskDependencyType;

  private constructor(context: TemporaryContext, dep?: ExecuteQueueTaskDependencyType) {
    this.context = context;
    this.dep = dep ?? ExecuteQueueTaskDependency.getDefault(orgId, context);
  }

  public handle = async (arg: {queueId: string}): Promise<void> => {
    const queue = await this.dep.queueRepository.findById(arg.queueId);
    if (!queue) {
      throw new UsecaseCommonError('queue-not-found', arg, {status: HttpStatus.NOT_FOUND});
    }
    if (queue.isProcessing()) {
      throw new UsecaseCommonError('queue-processing', {queue: queue.getData()});
    }

    const nextTask = queue.getAndStartNextTask();
    if (!nextTask) {
      // 次のタスクがないので処理不要
      return;
    }

    // 処理開始前に永続化 (statusをprocessingに更新する)
    await this.dep.queueRepository.save(queue);

    // 処理を実施
    await this.dep.taskService
      .handle(queue.findSubtask(nextTask.getData().entrySubtaskId))
      .then(res => {
        if (res.success) {
          // 処理成功時の処理 (statusをsuccessに更新するなど)
          queue.onSuccess();
        } else {
          // 処理成功時の処理 (statusをfailedに更新しリトライするかどうかの判断など)
          queue.onFail({code: res.code, massage: res.message});
        }
      })
      .catch(error => {
        queue.onFail({code: error.code, massage: error.message});
      });

    // 処理結果を永続化
    await this.dep.queueRepository.save(queue);
  };
}

4-b. ポイント

  • 全体的にスッキリした!!
  • 非同期処理は全てdepにまとめて、注入できる仕組みとしている
    • まとめることにより外から見て依存を把握しやすい。dependencyTypeをみれば把握できる
    • 外から依存(Dependecy)を注入できるので、テスタブル!!!!!
  • Queueの管理にまつわるロジックは全てDomainObject(Queue)内に隠蔽
    • Usecaseの処理がスッキリして、全体像を把握しやすくなる
    • Queueの振る舞いに関してはDomainObject(Queue)をみれば全て把握することができる
  • 処理を 取得(非同期) 加工(同期的) 更新(非同期) に分解して記載
    • Usecaseからみて意図しない更新処理が付け入る余地を最大限排除
  • 複数テーブルへの変更もDomainObject(Queue)に隠蔽されているため漏れは起きえない
  • 変更のデータ生成もDomainObject(Queue)に隠蔽しているため不整合にもならない
  • QueueのDomainの扱いと個別の処理(TaskService)の呼び出しのみにフォーカスできている

4-c. After 詳細

参考までに上記のUsecase以外の部分の説明を記載します。あまり洗練しきれていないのと、今回の議論の主要ポイントではないので参考程度にみていただければ幸いです。

(参考) モデル図

用いたモデルのクラス図

(参考) モデル説明

Queue

  • Queue を管理するためのデータ
  • 処理中か否か、処理している task がなにかなどを管理する

QueueTask

  • Queue に積まれたタスク
  • 処理中となるタイミングで削除され、処理に失敗しリトライ対象となったタイミングで再生成される
  • 基本的には enqueue された日時順にタスクを実行する
  • 優先度が指定されて言う場合には優先度が高い順に実行する

QueueTaskResult

  • Queue の実行結果を管理するためのデータ
  • 処理失敗してリトライする場合などは、リトライごとに結果データを生成する
  • TaskEntry には最終的な結果のみ格納する

QueueEntry

  • Queue タスクを管理する元データ
  • タスク生成時に本データが生成される
  • タスク実行に必要な情報と、最低限のステータスの管理を行う
  • 実際に Queue 上のデータ遷移を管理するデータは別

QueueEntrySubtask

  • 1 つの Entry を実現するために複数の Task を実行する必要がある場合に複数の Task を SubTask として管理する
(参考) 処理フロー

Task の作成

  1. Entry & Queue/{id}/Tasks を作成する

  2. Queue で処理中の対象がなければ、Trigger を更新して Trigger 処理を起動させる (処理中の対象があれば何もしない)

Queue の実行

  1. QueueTrigger の更新をキーにして処理が実行される (firestore.trigger)

  2. trigger が実施された際には Entry を Queue をチェックして処理中のタスクがあるかチェック、あれば何もしない

  3. 処理中のタスクがなければ、以下を実施

- QueueTasks から優先度等を参照し次に実行すべきTaskを特定する
- 取得した id を Queue に処理中の対象として更新
  1. 処理を実施 (場合によっては API)

  2. 処理成功時に以下を実施

- QueueTaskを削除
- QueueTaskResult を追加
- QueueSubtask のステータスを更新
- QueueEntryに残のQueueSubtaskがなければQueueEntryのステータスを更新
- QueueEntryに残のQueueSubtaskがあれば、QueueTaskに積む
- Queueの情報を更新
- Triggerを更新して次の処理を起動
  1. 処理失敗時に以下を実施
- QueueTaskを削除
- QueueTaskResult を追加
- リトライ状況を確認し一定回数連続で失敗していなければ、条件を変えずにQueueTaskに積む
- リトライ状況を確認し一定回数連続で失敗していれば、Queueの最後にQueueTaskを積み直す
- リトライ状況を確認し合計で一定回数以上失敗していれば、Queueに積まず削除扱いとする
- QueueSubtask のステータスを更新
- QueueEntryのステータスを更新
- Queueの情報を更新
- Triggerを更新して次の処理を起動
  1. timeout or 非同期処理の場合
- 一定時間後に処理結果を確認する処理を scheduler に設定
- (本当は linkDevice 側に callback 関数を用意して欲しい)
(参考) EntityやValueObjectの実装

DomainObject (Queue) の実装内容

type DataType = {
  readonly category: QueueCategoryValue;
  readonly attributes: {
    linkDeviceId?: string;
  };
  processingTaskId?: string;
  startAt?: number;
  lastTaskFinishAt?: number;

  tasks: QueueTaskFirstCollection;
  entries: QueueEntryFirstCollection;
};

/**
 * Queueで実現したい情報
 * 以下は管理の対象外
 * ・Queueで実施すべきTaskの順序
 */
export class QueueEntity<T = Record<string, unknown>> extends AbstractLooseEntity<DataType & T> {
  /**
   * 新規のインスタンスの生成処理
   * 永続化時には新規扱いで登録される
   * @param data
   */
  protected static _create = <T>(data: EntityDataType<QueueEntity<T>>) => {{
    return new QueueEntity({
      ...data,
      // TODO Firestoreでのソート対応のため日時をプレフィックスにする
      id: uuid.v4(),
    });
  };

  /**
   * 永続化済みの情報をもとにインスタンスを生成する場合の処理
   * 永続化時には更新される
   * @param data
   */
  public static reconstruct = (data: EntityDataTypeWithId<QueueEntity>) => {
    return new QueueEntity(data, {isReconstruct: true});
  };

  /**
   * Queueがタスク処理中か否かのチェック
   */
  public isProcessing = (): boolean => !!this.data.processingTaskId;

  /**
   * Queueを用いて実行する処理を追加する
   * @param entry
   */
  public entry = (entry: QueueEntryEntity): void => {
    const subtaskId = entry.detectNextSubtaskId();
    if (!subtaskId) {
      throw new DomainCommonError('entry-has-no-sub-task', {entry});
    }

    this.data.entries.push(entry);
    this.data.tasks.push(
      QueueTaskEntity.create({
        entrySubtaskId: subtaskId,
      })
    );
  };

  /**
   * QueueTaskに積まれているTaskから次のタスクを選定し、セットする
   * Queueが処理中の場合にはエラーとする
   * 主な処理内容は以下
   * ・Queue自体のステータス更新
   * ・QueueEntryのステータス更新
   * ・QueueEntrySubTaskのステータス更新
   */
  public startNextTask = (): PickEntityBasisMethod<QueueTaskEntity> | undefined => {
    const nextTask = this._detectNextTask();
    if (this.isProcessing() || !nextTask) {
      return undefined;
    }

    // Entry / Subtaskを処理中にする
    this.data.entries.onProcessingWithSubtaskId(nextTask.get().entrySubtaskId);

    // Queueのステータス更新
    this.data.processingTaskId = nextTask.id();
    this.data.startAt = new Date().valueOf();

    return nextTask;
  };

  /**
   * 処理が成功した際の処理
   */
  public onSuccess = () => {
    const {entries, tasks} = this.data;

    if (!this.isProcessing() || !this.data.processingTaskId) {
      throw new DomainCommonError('not-processing-and-could-not-success', {data: this});
    }
    const queueTask = this._detectQueueTask(this.data.processingTaskId);
    const subtaskId = queueTask.get().entrySubtaskId;

    entries.onSuccessWithSubtaskId(subtaskId);

    tasks.onSuccess(this.data.processingTaskId, {
      startAt: this.data.startAt ?? new Date().valueOf(),
      retriedCount: entries.findSubtaskByIdWithError(subtaskId).get().retriedCount ?? 0,
    });

    this._removeQueueTask(queueTask.id());
    this.data.lastTaskFinishAt = new Date().valueOf();
    this.data.processingTaskId = undefined;
    this.data.startAt = undefined;

    const nextSubtaskId = entries.detectNextSubtaskIdInSameEntry(subtaskId);
    if (nextSubtaskId) {
      tasks.push(
        QueueTaskEntity.create({
          entrySubtaskId: nextSubtaskId,
        })
      );
    }
  };

  /**
   * 処理が失敗した際の処理
   */
  public onFail = (arg: {code: string | undefined; massage: string | undefined}) => {
    const {entries, tasks} = this.data;
    if (!this.isProcessing() || !this.data.processingTaskId) {
      throw new DomainCommonError('not-processing-and-could-not-fail', {data: this});
    }
    const taskId = this.data.processingTaskId;
    const queueTask = this._detectQueueTask(this.data.processingTaskId);
    const subtaskId = queueTask.get().entrySubtaskId;

    entries.onFailWithSubtaskId(subtaskId);

    // FIXME パラメータの正常化、retriedCountはsubtaskから取得するようにする
    tasks.onFail(taskId, {
      startAt: this.data.startAt ?? new Date().valueOf(),
      retriedCount: entries.findSubtaskByIdWithError(subtaskId).get().retriedCount ?? 0,
      code: arg.code ?? 'unknown',
      message: arg.massage ?? 'unknown',
    });
    if (entries.shouldRetryInARowWithSubtaskId(subtaskId)) {
      // そのまま再実行する場合にはQueueTaskは何もしない
    } else if (entries.shouldRetryWithSubtaskId(subtaskId)) {
      // 順番を後回しにして実施する
      tasks.reEnqueue(taskId);
    } else {
      // リトライしない場合にはQueueTaskから除外する
      tasks.remove(taskId);
    }

    this.data.lastTaskFinishAt = new Date().valueOf();
    this.data.processingTaskId = undefined;
    this.data.startAt = undefined;
  };

  /**
   * Queueに積まれているTaskから次に処理すべきQueueTaskを特定する
   */
  private _detectNextTask = (): PickEntityBasisMethod<QueueTaskEntity> | undefined =>
    this.data.tasks.getHighestPriority();

  private _detectQueueTask = (taskId: string): PickEntityBasisMethod<QueueTaskEntity> => {
    const queueTask = this.data.tasks.findById(taskId);
    if (!queueTask) {
      throw new DomainCommonError('queue-task-not-found', {taskId, data: this});
    }
    return queueTask;
  };

  /**
   * Queueに追加されているTaskを除外する処理
   * 処理が成功した場合や最大リトライ回数を超えて失敗した場合などに実施する
   * @param taskId
   */
  private _removeQueueTask = (taskId: string) => {
    if (!this.data.tasks.include(taskId)) {
      throw new DomainCommonError('queue-task-not-found', {taskId, data: this});
    }
    this.data.tasks.remove(taskId);
  };

  public findSubtask = (subtaskId: string): PickEntityBasisMethod<QueueEntrySubtaskEntity> => {
    const subtask = ArrayUtil.removeUndefined(this.data.entries.map(entry => entry.get().subtasks.findById(subtaskId)));
    if (subtask.length === 0) {
      throw new DomainCommonError('subtask-not-found', {subtaskId, data: this});
    } else if (subtask.length > 1) {
      throw new DomainCommonError('subtask-duplicate', {subtaskId, data: this});
    }
    return subtask[0];
  };
}

ポイント

  • 継承することで基本的なメソッドは追加実装不要な状態としている
  • 永続化済みの情報からインスタンス化する際のメソッドとしてreconstructを用意している
  • インスタンス化するメソッドもstatic関数で用意し、1つのクラスに集約している
  • constructorは公開しない
  • Entityを返却する際には、変更を要さないAbstractクラスで実装されている基本メソッドに限定した型で返却している。(あくまで型の扱いのみなので、無理やりよべばよべる)
  • 子要素に隠蔽できるロジックは隠蔽してQueueはQueueとして管理すべき振る舞いに注力している。(startNextTaskなど参照)
  • getterをいちいち実装しなくても、デフォルトで利用できるようにしている(Abstractクラス)
  • 型制約上は外から変更できないようにしている

5. 学び

今回の営みを通して学んだことをまとめます。

  1. 課題を明確にしてから導入することで、達成すべきポイントが明確となり、既存ソースと並行で段階的に導入するなど導入時のバリエーションにつながること。
  2. まずは形からドメインオブジェクトを実装する(カプセル化と知識の集約を推し進める)という方法を取りましたが、とりあえず形を敷くことでモデリングに注力しやすい環境を整えることができ、ボトムアップの方法が効果的であること。
  3. メソッドに渡すパラメータをEntity等のモデルに統一でき、メソッド実行のためのパラメータの詰替処理の記述を低減できるという副次効果もあること。
  4. 特定の領域でスモールスタートしてみて、一定安定的にできるようになってから全体に波及していく方法が良いこと。(実際にやってみると改善すべきポイントや考慮すべきポイントはどんどん出てくるので、最初から広げすぎるとカオスになるかなと思います...)
  5. 特に必要な実装ファイルとその責務、ディレクトリ構成、DomainServiceやValueObjcectで解決したい課題と対応する領域しない領域の指針を決めておくと進めやすい。(けっこう広げる際にはチームメンバーが悩む部分となるので、できるだけ指針を事前に決めておいたほうが展開しやすいです。)

6. おまけ

実際にDDDをどのように段階的に導入していったかについて割愛しているので、イメージつかみきれない部分あるとは思いますが...(ぜひ実践編への期待と、勉強会に参加お願いします!)

勉強会の告知

7/20(水)に 「DDD勉強会」 を実施しました!(資料はこちら)
3社のエンジニアがTLとパネルディスカッションを行います。
興味がある方はぜひ気軽に参加してください!
第2回目は8/24(水)に予定しています!(こちら)

twitterフォロー

最近twitter始めたのでフォローしていただけると嬉しいです!
※ アカウントは結構昔に作っていたのですがほぼ活動しておらずで、最近ちゃんと活動し始めました...w

44
31
1

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
44
31