実務Webアプリ開発編です。前回はエンティティ間の関連をEF Coreでマッピングし、「関連の線」を引きました。

今回はその線1本1本に、「親の行が消えたとき、それを参照する子の行はどうなるか」(削除ルール:Cascade/Restrict)を決めていきます。

今回のポイントは次の2つです。

  • 削除ルールの答えは、集約境界にすでに書いてある(同一集約内の参照ならCascade、別集約への参照ならRestrict)
  • DBのRestrictは最後の安全網。削除するかどうかの判断はアプリケーション層の仕事(そもそも削除しない、という判断も含めて)

以下のような方に役立つ内容となっています。

  • OnDeleteのCascade / Restrictをどちらにすべきか、毎回迷う
  • DDDの集約境界と削除がどのように関係するのかを知りたい
  • EF Coreで削除をどのように扱うのか知りたい
  • データを物理削除してよいのか、論理削除にすべきか判断できない

以下のようなMentorAppを題材として進めます。

GitHubにドキュメント・コードの一式があります。

今回の内容は、集約境界の設計と、前回の関連マッピングが土台になります。以下の記事とつなげて読むと理解しやすいです。

【C#/Blazor】実務Webアプリ開発編 (21)DDDの集約境界の決め方と守り方 ~TopicとMessageの親子関係を学ぶ~ 実務Webアプリ開発編です。Part IV(ドメイン層の実装)として、前回はUserやMentorshipを題材に、集約ルート自身の状...
【C#/Blazor】実務Webアプリ開発編 (26)EF Coreでエンティティの関連をマッピングする ~ナビゲーションプロパティ・外部キー・規約~ 実務Webアプリ開発編です。前回はPart V(永続化とCQRS)の初回として、エンティティ単体をEF Coreでテーブルにマッピング...
プロ太

前回で関連の「線」は引けました。今回はその線に「親が消えたら子はどうする?」というルールを1本ずつ決めていきます。

アプリとして「永続データの削除」をどう扱うかという点についても説明します。

削除ルールという設計判断

まずは一般論として、削除ルールとは何を決めるものなのか、そして何を基準に選ぶのかを整理します。

親が消えたら子はどうなるか

本とレビューのような、DB上の1対多関係を考えます。ここでは、参照される側(本)を親、FKで参照する側(レビュー)を子と呼びます。親の行を削除したとき、その親を参照している子の行はどうなるべきでしょうか。

前回、生成されたテーブル定義のFK制約にON DELETE CASCADEという句が付いていました。あれがまさに、この問いへの答え=削除ルールです。

削除ルールの主な選択肢は次の3つです。

削除ルール親の行を削除すると
Cascade子の行も一緒に削除される
Restrict子がいる限り、親の削除は失敗する
(先に子を全部削除する必要あり)
SetNull子の行は残り、親への参照(FK列)だけがNULLになる

実際によく使うのはCascadeとRestrictの2つです。SetNullは「親なしでも子が成立する任意の関連」に限られ、今回扱う必須関連では選べないためです。

SetNullは、FK列がNULLを許す関連でしか選べません。ただし、NULLを許せるからといって積極的に選ぶものではありません。

参照先を削除してNULLにすると、「最初から参照がなかった」のか「削除によって失われた」のかを区別できず、過去の関係もたどれなくなります。

さらに、参照がない状態を業務ロジック全体で正しく扱う必要も生まれます。

そのため実務では、削除をRestrictで防ぐ、参照先を「廃止」という状態で残す、明示的な「未分類」を用意する、といった設計の方が適切なことも多くあります。

SetNullを選ぶのは、参照を失った状態が業務上も正当で、履歴を残す必要もない場合に限られます。

重要なのは、技術的にはどれを選んでも設定1行だということです。書くこと自体は簡単で、難しいのは「どれを選ぶか」です。

選択の基準は集約境界にある

では、何を基準に選べばよいのでしょうか。DDDには、明確な基準があります。集約境界です。

ここから扱う「集約ルートと子エンティティ」は、先ほど説明したDB上の親子とは異なる、DDDのモデル上の区分です。

第21回で、集約は「一緒に生まれ、一緒に守られ、一緒に消える単位」だと学びました。

この定義が、そのまま削除ルールの答えになります。同一集約のルートと子エンティティは運命共同体なのでCascade、別集約への参照は独立した存在なのでRestrictです。

プロ美

Cascadeだと、親が削除されると子は道づれでまるっと削除されるってことだね。

あれ、そうするとRestrictの場合、親を参照しているデータの後始末は誰がやるの?参照しているデータが残っていると、親も削除できないよね。

プロ太

Restrictにすると、削除に伴う後始末(親を参照しているデータをどうするか)はEF Core・DBの仕事ではなくなります

では誰の仕事になるのか——DDDではApplication層の責務です。この話は後半で詳しく見ます。

ミニプロジェクトで動かす

CascadeとRestrictの違いを、前回のBook+Reviewプロジェクトの続きで体感してみます。データベースも前回と同じLocalDBです。

コードはGitHubの27_EFCore削除ルール基礎に置いてあります。

Cascade削除を体感する

まず、Reviewを2件持つBookを保存し、そのBookを削除してみます。削除ルールに関する設定は、まだ何もしていない状態です。

// Program.cs(抜粋)
// ...(DBの作り直しと、Reviewを2件持つBookの保存。前回と同じ)

Console.WriteLine("=== 2. Bookを削除してみる ===");
await using (var db2 = new AppDbContext())
{
    // 子Reviewは読み込まず、親Bookだけを削除する
    Book book = await db2.Books.SingleAsync();
    db2.Books.Remove(book);
    try
    {
        await db2.SaveChangesAsync();
        Console.WriteLine("削除できました");
    }
    catch (DbUpdateException ex)
    {
        // 削除ルールによっては失敗するので、結果を確認できるようにしておく
        Console.WriteLine($"削除に失敗しました: {ex.InnerException?.Message}");
    }
}

Console.WriteLine();
Console.WriteLine("=== 3. 残っている行数を確認する ===");
await using (var db3 = new AppDbContext())
{
    Console.WriteLine($"Books: {await db3.Books.CountAsync()} 件 / Reviews: {await db3.Reviews.CountAsync()} 件");
}

実行結果は次のとおりです。

=== 1. Reviewを2件持つBookを保存する ===
保存しました

=== 2. Bookを削除してみる ===
削除できました

=== 3. 残っている行数を確認する ===
Books: 0 件 / Reviews: 0 件

Bookしか削除していないのに、Reviewも一緒に消えました

EF Coreでは、FKがnull不可の「必須関連」の場合、削除ルールの既定はCascadeです。

前回のテーブル定義に付いていたON DELETE CASCADE句は、この既定が現れたものだったわけです。

Restrictに変えると削除できなくなる

次に、削除ルールをRestrictに変えてみます。GitHubのAppDbContext.csでは、Restrictの設定を次のようにコメントアウトしてあります。

// AppDbContext.cs(全体・GitHubに置いてある状態)
public class AppDbContext : DbContext
{
    public DbSet<Book> Books => Set<Book>();
    public DbSet<Review> Reviews => Set<Review>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseSqlServer(
            "Server=(localdb)\\MSSQLLocalDB;Database=EfCoreDeleteRuleBasics;Trusted_Connection=True;TrustServerCertificate=True");

    //protected override void OnModelCreating(ModelBuilder modelBuilder)
    //{
    //    // 削除ルールを既定(Cascade)からRestrictに変更する
    //    modelBuilder.Entity<Book>()
    //        .HasMany(b => b.Reviews)
    //        .WithOne()
    //        .OnDelete(DeleteBehavior.Restrict);
    //}
}

このコメントアウトを解除して、もう一度実行しましょう。削除ルールが、既定のCascadeからRestrictに変わります。

削除のコードは先ほどとまったく同じまま、再実行してみます。

=== 1. Reviewを2件持つBookを保存する ===
保存しました

=== 2. Bookを削除してみる ===
削除に失敗しました: The DELETE statement conflicted with the REFERENCE constraint "FK_Reviews_Books_BookId". The conflict occurred in database "EfCoreDeleteRuleBasics", table "dbo.Reviews", column 'BookId'.

=== 3. 残っている行数を確認する ===
Books: 1 件 / Reviews: 2 件

今度はFK制約違反で削除が失敗し、BookもReviewも残っています。「レビューがいる限り、本は消せない」というわけです。

なお、先にReviewをすべて削除しておけば、Bookは問題なく削除できます。

プロ美

えー、消したいのに消せないって、不便じゃない?

プロ太

逆なんです。気づかないうちに子まで消えてしまう方が、ずっと怖いんですよ。

Restrictは「その削除、参照している子がいますよ」と教えてくれる安全装置だと考えてください。

この2つの削除ルール(Cascade、Restrict)のDDDにおける使い分けを、MentorAppのコードで実際にみてみましょう。

MentorAppの実装をみる

ここからは、MentorAppの実コードで削除ルールを見ていきます。登場する集約とファイルの位置関係は次のとおりです。

src/
├─ MentorApp.Domain/
│  └─ Models/
│     ├─ Users/
│     │  └─ User.cs              ← User集約
│     ├─ Mentorships/
│     │  └─ Mentorship.cs        ← Mentorship集約
│     └─ Topics/
│        ├─ Topic.cs             ← Topic集約(集約ルート)
│        └─ Message.cs           ← Topic集約の子エンティティ
├─ MentorApp.Application/
│  └─ Mentorships/
│     └─ MentorshipService.cs    ← 削除の後始末(この章の後半に登場)
└─ MentorApp.Infrastructure/
   └─ Persistence/
      └─ AppDbContext.cs         ← 削除ルールの設定(今回の主役)

集約はUser・Mentorship・Topicの3つです。MessageだけがTopic集約の子エンティティで、単独の集約ではありません。

削除ルールの設定は、前回と同じくAppDbContext.csに集約されています。

この章で確認するのは、次の4点です。

  • 削除ルールの全体像——Cascadeは同一集約の1か所だけ
  • 同一集約はCascade——コードはOnDeleteの1行だけ
  • 別集約への参照はRestrict——本当に削除したいときの判断はApplication層が持つ
  • そして実際には、あまり削除しない——終わりは状態で表す

削除ルールの全体像 ~Cascadeは同一集約の1か所だけ~

MentorAppの関連は5本ありました。それぞれの削除ルールを、集約境界と重ねて図にすると次のようになります。

結論はシンプルです。Cascadeは、同一集約であるTopic→Messagesの1か所だけ集約の枠をまたぐ残りの4本は、すべてRestrictです。

プロ美

Cascadeって1か所しかないの!?もっとあちこちで使うものかと思ってた。

プロ太

集約の枠の中だけです。枠をまたぐ線は全部Restrict——図の見た目そのままですね。

では、この図がコードでどう表現されているかを、CascadeとRestrictの代表1つずつで確認していきます。

同一集約はCascade ~Topic→Messages~

まず同一集約の側です。TopicとMessageは、第21回でTopicを集約ルートとする同一集約として設計しました。削除ルールとしてCascadeを指定しています。

// AppDbContext.cs(抜粋)
private static void ConfigureTopic(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Topic>(entity =>
    {
        // ...

        // Topic削除時に同一集約であるMessagesも連鎖削除する
        entity.HasMany(e => e.Messages)
            .WithOne()
            .OnDelete(DeleteBehavior.Cascade);
    });
}

コメントに「同一集約であるMessagesも連鎖削除する」とあるとおり、この1行は設計意図の記録にもなっています。

第21回の「一緒に消える単位」という集約の定義が、DeleteBehavior.Cascadeという具体的なルールになっているわけです。

プロ太

この1行は書かなくても同じ動きになります。ミニプロジェクトで見たとおり、必須の関連は何も書かなくても既定でCascadeになります。

それでもあえて1行書くのは、これが規約まかせの結果ではなく設計判断だと明示的に残すためです。

別集約への参照はRestrict ~「消せない」という設計~

次は、集約の枠をまたぐ側です。まず代表となるコードを読み、そのうえで「では本当に消したいときはどうするのか」まで見ていきます。

Mentorship→Userの参照

代表として、MentorshipからUserへの参照を見ます。メンターとメンティの2本が、どちらもUserを指しています。両方とも削除ルールはRestrictを指定しています。

// AppDbContext.cs(抜粋)
private static void ConfigureMentorship(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Mentorship>(entity =>
    {
        // ...

        // User削除時の連鎖削除を防ぐ(同一集約ではない)
        entity.HasOne(e => e.MentorUser)
            .WithMany()
            .OnDelete(DeleteBehavior.Restrict);

        entity.HasOne(e => e.MenteeUser)
            .WithMany()
            .OnDelete(DeleteBehavior.Restrict);
    });
}

残りの2本(Topic→Mentorship、Message→SenderUser)も、まったく同じ形でRestrictを指定しています。(コードは省略しますが、考え方はこの代表例と同じです)

仮に、すべての関連をCascadeにできたとしたらどうなるでしょう。Userを1人削除しただけで、そのユーザーのMentorshipが消えます。

さらにその下のTopic、Messageまで連鎖して、相談の履歴が丸ごと消えてしまいます。Restrictは、この事故をDBの制約レベルで確実に止めてくれます。

Restrictは最後の安全網 ~削除の後始末はApplication層の仕事~

Restrictにすれば、参照する子がいる限り削除は失敗します。ただ、これで話は終わりではありません。

プロ美

じゃあ、本当にMentorshipを消したいときはどうするの?DBが止めちゃうんでしょ?

プロ太

削除してよいかの判断と後始末は、Application層のユースケースの仕事なんです。

DBのRestrictは、その判断をすり抜けたときの最後の安全網という位置づけですね。

実例を見てみましょう。Mentorshipを削除するユースケース(MentorshipService.cs・Application層)です。

MentorAppの仕様は、管理者が、Topicを1つも持たないMentorshipだけを削除できるというものです。

// MentorshipService.cs(Application層・抜粋)
public async Task DeleteMentorshipAsync(
    Guid mentorshipId,
    CurrentUser currentUser,
    CancellationToken cancellationToken = default)
{
    // ...
    await using var uow = await unitOfWorkFactory.CreateAsync(cancellationToken);

    var mentorship = await uow.Mentorships.FindByIdAsync(mentorshipId, cancellationToken)
        ?? throw new KeyNotFoundException($"メンタリング関係が見つかりません: {mentorshipId}");

    // ...(管理者権限のチェック)

    // 参照しているTopicの有無を、業務ルールとして先に確認する
    if (await uow.Topics.HasAnyByMentorshipIdAsync(mentorshipId, cancellationToken))
        throw new InvalidOperationException(
            "トピックが存在するメンタリングは削除できません。終了する場合は完了または中止を使用してください。");

    uow.Mentorships.Delete(mentorship);
    await uow.SaveChangesAsync(cancellationToken);
    // ...
}

DBのRestrictに任せてFK例外を捕まえるのではなく、業務ルールとして先にTopicの有無を確認しています。

FK制約違反の無骨な例外は、想定外の経路に対する防波堤として控えているだけです。

そもそも削除しないという選択 ~終わりは状態で表す~

最後に、一歩引いた話をします。実はMentorAppには、削除の機能がほとんどありません

削除できるのは、さきほど見たMentorship集約だけです。しかも管理者専用で、Topicが1つもない場合に限られます。

実質的には、誤って作ってしまった関係を取り消すための機能です。User集約・Topic集約には、削除の機能自体がありません。

3つの集約が「終わり」をどう表しているかを並べると、次のようになります。

  • User:削除はできない。
  • Mentorship:完了(Completed)/中止(Cancelled)という状態で終了を表す。誤って作った場合に削除可能。
  • Topic:クローズ(Closed)という状態で投稿を締める。削除はできない。

なぜ削除しないのでしょうか。根っこにあるのは、MentorAppが「やりとりの履歴をすべて残す」という方針を選んでいることです。

誰がいつ何を相談し、どう応じたか。Topic・Messageは、そのやりとりの記録そのものです。

Userも、その記録の中に送信者や当事者として登場します。Mentorshipは、誰と誰の関係だったかという事実です。

いずれも過去に起きた事実なので、消してよいものがありません。だから削除の機能を作らない、という判断になります。

削除ルールを決めることと、削除機能を作ることは別です。全関連にルールを決めたうえで、機能としてはほとんど削除しない——これも1つの設計です。

実務でも、行を消さずに状態で「終わり」を表す設計は広く使われます。履歴・監査・誤操作からの復旧を考えると、その方が安全だからです。

規模が大きくなると、終わった記録が溜まってきます。そこで「アーカイブ」という状態を設け、現役のデータと分ける段階に進むことがあります。

さらに古いものは別の保管領域へ移す、保存期間を過ぎたものだけを物理削除する、といった運用へ発展していきます。削除は最後の手段というわけです。

なお、全テーブルに削除フラグ(IsDeleted)を持たせる「論理削除」もよく見かけますが、DDDとは相性がよくありません。

「削除された」は業務上の状態ではなく、何が起きたのかを表していないからです。終了なのか、中止なのか、保管なのかが分かりません。

完了・中止・アーカイブのように、業務の言葉で状態としてモデル化する。それがここまで見てきたMentorAppのやり方です。

まとめ

今回は、前回引いた関連の線1本1本に、削除ルールを決めてきました。重要なポイントは以下です。

  • 削除ルール(Cascade/Restrict)は技術設定ではなく設計判断。同一集約ならCascade、別集約への参照ならRestrict
  • 集約境界を決めた時点で、削除ルールの答えは出ている。Cascadeが必要になるのは同一集約の内側だけ
  • DBのRestrictは最後の安全網。削除してよいかの判断と後始末は、Application層の仕事
  • 削除ルールを決めることと、削除機能を作ることは別。終わりは削除ではなく、業務の言葉の状態で表す

次回は、第22回で定義したリポジトリインターフェースを、EF Coreで実装します。

そこでは「DBから集約を取り出すとき、関連するデータをどこまで一緒に読み込むか」を決めます。

Topicを読むならMessageも一緒に、けれど別集約のUserまでは追わない——このような線引きも、今回の集約境界の話の続きです。

プロ太

削除ルールは、集約設計をそのまま形にしたものでした。設定はOnDeleteの1行でも、そこには「消す・消さない」の判断が詰まっています。

引き続き、リポジトリの実装について一緒に学んでいきましょう!

ABOUT ME
プロ太
ソフトウェア開発を楽しく、効率的に行う方法を追求しています。 開発者の視点から技術的課題に向き合い、「純粋な技術的興味に基づく探求」と「実践的な課題解決」という二つの柱を両輪として活動しています。