👻

DDDでcascade deleteしよう!

2025/01/27に公開

はじめに

こちらは「medicalforce New Year's Blog 2025」15日目の記事です。

あけましておめでとうございます🎍
警備フォースエンジニアのたいと申します!
2025年は12分の1が終わろうとしていることに恐怖を感じていますが、皆様はいかがお過ごしでしょうか?

メディカルなのに警備って???

「メディカルフォースって自由診療クリニック向けのSaaSを展開している会社じゃないの?」と思ったそこの方! いい質問です👍
弊社は「バーティカルSaaSのコングロマリット戦略」、つまり様々な産業に特化したSaaSを展開する方針をとっており、警備フォースはその第二弾というわけです。

さて、警備フォースでは開発の初期からDDD(ドメイン駆動設計)オニオンアーキテクチャを採用しています。黎明期と言える開発フェーズで、関連データ削除(cascade delete)処理を実用的かつ理想的な形に仕上げられたように思いますので、今日はその話をしたいと思います🍵

前提:DDD(ドメイン駆動設計) × オニオンアーキテクチャ[1]

今回は概要の説明に留めますが、DDDとオニオンアーキテクチャを組み合わせた場合、ドメイン知識(問題解決したい領域のルール、制約)を中心に据えて、それにアプリケーションロジックを担うユースケース層が依存し、さらにその外側にDB接続等を担うインフラ層とエンドポイント定義を担うプレゼンテーション層が依存する形で配置されます。


これにより、

  • ドメイン層が他層の技術的詳細に依存しない
  • 各層が高凝集低結合となる
    ため、保守性が高くなるなどのメリットがあります。

関連データ削除処理

DDDでは、集約という単位でDBからのデータの出し入れを行います。
同一集約内の関連データ削除処理についてはrepositoryの実装クラスでそのまま削除して問題ないですが、集約を跨ぐ場合は少し工夫する必要があります。 
結論としては、子テーブルのDeleterを実装し、これを親のrepositoryの削除系メソッドから必ず呼び出すような仕組みを作りました。

子テーブルを一緒に削す処理

下記のようなテーブルがあり、親であるProjectsのレコードを消したら子テーブルであるTasksのレコードも消したいとします。

この場合、下記のように実装します。

  • ProjectのIdを元にTaskを削除する処理であるTaskDeleterByProjectIdを実装
  • ProjectRepositoryのdeleteメソッドの引数にtaskDeleterByProjectIdを指定

interface

まず先述の通りにinterfaceを修正します。

service/taskDeleterByProjectId.ts
+ export interface ITaskDeleterByProjectId {
+   exec: (projectId: ProjectId) => Promise<void>;
+ }
project/repository.ts
 export interface IProjectRepository {

   delete: (
     projectId: ProjectId,
+    taskDeleterByProjectId: ITaskDeleterByProjectId
   ) => Promise<void>;
  }

ここで、DDDにおいてrepositoryのinterfaceはrepositoryの仕様定義の役割も担っていることに注意すると、IProjectRepositoryを見るだけで

  1. Projectの削除はProjectのIdを用いて実行すること
  2. ProjectのIdを用いてTaskを削除すること

の2点が一目見てわかります。また、関連データ削除処理の実装漏れを防ぐことができます。

さらに、 引数のtaskDeleterByProjectIdの型にITaskDeleterByProjectIdを指定するのがポイントです💡
これにより、usecaseからprojectRepositoryのdeleteメソッドを呼び出す際に、第二引数に別のDeleterを渡すようなミスを防ぐことができます。

実装

Deleterの実装クラスについては、基本的にはrepositoryの削除系メソッドを呼び出すことになります。この場合は、削除処理の実装がrepositoryによって隠蔽されているため実装クラスはdomain層に配置して問題ないです。

一方で、集約外でかつ集約ルートではないテーブルなどrepositoryのロジックが存在しない(repositoryは各集約につき1つ)場合は、ORマッパー等に依存した実装をする必要が出てくるためDeleterの実装クラスをインフラ層に配置する必要があります。

usecaseからの呼び出し例
usecase/project/delete.ts
this.projectRepository.bulkDelete(
  projectId,
  this.taskDeleterByProjectId
);

孫テーブルも削除したい場合

なぜかというと、各々の削除処理では自分と子テーブルの削除仕様をケアするだけで全体としては親子孫の削除処理の実装ができるからです!
TaskCommentが紐づいている例を用いて、以下で説明します。

先ほどと同様に、interfaceを修正します。

interface
task/repository.ts
 export interface ITaskRepository {

   delete: (
     taskId: TaskId,
+    commentDeleterByTaskId: ICommentDeleterByTaskId
   ) => Promise<void>;
  }
service/commentDeleterByProjectId.ts
+ export interface ICommentDeleterByProjectId {
+   exec: (taskId: TaskId) => Promise<void>;
+ }

先ほど、Deleterの実装はrepositoryの削除系メソッドを呼び出すことになると説明した通り、Deleterの実装クラスにおける該当部分にITaskRepositoryのdeleteメソッドの変更を反映します。

service/taskDeleterByProjectId.tsの実装クラス
    this.taskRepository.delete(
      taskId,
+     this.commentDeleterByTaskId
    );
}

このように、taskDeleterByProjectId経由でTask(子テーブル)を削除する際にTaskRepositoryのdeleteを呼び出すと、commentDeleterByTaskIdも渡さないといけなくなるので必然的にComment(孫テーブル)も削除することができます。

まとめ

今回採用した実装方針によって、下記が実現できました👏

  • 削除処理をドメイン知識として書けた
  • 削除処理が高凝集、疎結合になった
  • repositoryのinterfaceで、削除処理の際に子テーブルを削除するという仕様を読み取れる
  • 関連テーブルツリーが長く続いても自分と子テーブルのことだけ考えればOKなので、実装が楽かつ漏れやミスも防げる

usecaseで都度関連テーブルのrepositoryの削除系メソッドを呼び出す方法と比べて、保守性や拡張性が期待できるのではないかと思います。余談ですが、この方法を応用して削除時に集約を跨ぐ関連データの存在チェックを実装することもできます。
DDDでcascade deleteを実装する場合はぜひ取り入れてみてください!

おわりに

DBMSのcascade delete系オプションは使わないのか?

下記の理由からcascade delete系のオプション(例:MySQLのCASCADEなど)は使用せず、アプリケーションロジックとして実装する方針としました。

  • データ削除の仕様はドメイン知識として持つべきであるにもかかわらず、本来インフラ層が持つべきDBの仕様にドメイン層が依存してしまうため。
  • DDDでは常に正しい状態のentityのみが存在することを期待するとともに、repositoryはentityをそのまま永続化することが責務ですが、DBMSのcascade delete系オプションを使うとこの二つを共に破ってしまうため。

謝辞

松岡さん畠中さん@rryyooさん、実装方針の相談やレビュー等の対応頂きありがとうございました。

脚注
  1. 参考文献: https://booth.pm/ja/items/1835632 ↩︎

Discussion