じゃあ、おうちで学べる
じゃあ、おうちで学べる

じゃあ、おうちで学べる

はじめに

「論理削除?deleted_atカラム追加すればいいでしょ」この一言から始まる地獄を、何度見てきただろうか。

最初は簡単に見える。カラムを1つ追加するだけ。しかし、その「簡単さ」こそが罠だ。

論理削除は技術的負債の温床だ。WHERE句への条件追加忘れ、認知コストの増大、テストの複雑化、パフォーマンス劣化。すべては「最初にドメインを考えなかった」ツケである。

しかし現実として、サービスを運用していくと論理削除が必要になる場面は確実に訪れる。

論理削除の本質は、「このレコードは存在するが、存在しないことにしてほしい」という矛盾だ。この矛盾を解消するか、受け入れて安全に管理するか。本記事ではその両方のアプローチを解説する。

なお、私はDBのスペシャリストではないので、ここで紹介する方法が唯一の正解というわけではない。あくまで一つのアプローチとして参考にしてほしい。データベース設計は文脈次第で最適解が変わるため、「この記事に書いてあったから」ではなく、自分のプロジェクトに合うかどうかで判断してほしい。

このブログが良ければ読者になったりnwiizoXGithubをフォローしてくれると嬉しいです。

論理削除の何が問題なのか

WHERE句地獄

最も分かりやすい問題。すべてのクエリに AND deleted_at IS NULL を書く必要がある。

-- 単純なSELECT SELECT * FROM users WHERE id = 1 AND deleted_at IS NULL; -- JOINするたびに増える SELECT * FROM orders o INNER JOIN users u ON o.user_id = u.id AND u.deleted_at IS NULL INNER JOIN products p ON o.product_id = p.id AND p.deleted_at IS NULL WHERE o.deleted_at IS NULL; -- サブクエリでも忘れずに SELECT * FROM users WHERE id IN ( SELECT user_id FROM orders WHERE deleted_at IS NULL ) AND deleted_at IS NULL;

書き忘れたらどうなるか?削除したはずのデータが表示される。テストでは気づかない。本番で発覚する。深夜に電話が鳴る。

JOINが増えるほど、フィルタも増える ────────────────────────────────────────────────────────── SELECT * FROM orders │ ├──▶ WHERE orders.deleted_at IS NULL ← 1個目 │ ├──▶ JOIN users │ └──▶ AND users.deleted_at IS NULL ← 2個目 │ ├──▶ JOIN products │ └──▶ AND products.deleted_at IS NULL ← 3個目 │ └──▶ JOIN categories └──▶ AND categories.deleted_at IS NULL ← 4個目 テーブルが増えるたびに、書き忘れのリスクも増える 認知コストの増大

「このテーブル、論理削除だっけ?物理削除だっけ?」

この確認が、すべてのクエリを書くたびに発生する。

// このクエリ、deleted_atの条件入ってる? let users = sqlx::query_as!(User, "SELECT * FROM users WHERE status = 'active'") .fetch_all(pool) .await?; // レビュアー「deleted_atのフィルタ抜けてませんか?」 // 作者「あ、このテーブルは物理削除です」 // レビュアー「どこに書いてあります?」 // 作者「...」

ドキュメントに書いてあっても読まれない。コメントに書いてあっても見落とす。レビュアーの認知負荷が高い設計は、チーム規模が拡大するほど事故率が上がる。

一意制約の崩壊

論理削除を導入した瞬間、一意制約が意味をなさなくなる。

-- emailはユニークであるべき CREATE TABLE users ( id UUID PRIMARY KEY, email VARCHAR(255) UNIQUE, deleted_at TIMESTAMPTZ ); -- ユーザーAがemail "test@example.com" で登録 -- ユーザーAを論理削除 -- ユーザーBが同じemail "test@example.com" で登録しようとする -- → UNIQUE制約違反!

解決策はあるが、どれも美しくない。

-- 案1: 部分インデックス(PostgreSQL) CREATE UNIQUE INDEX idx_users_email_active ON users(email) WHERE deleted_at IS NULL; -- 案2: 削除済みは別の値にする UPDATE users SET email = email || '_deleted_' || id WHERE id = $1; -- 案3: 複合ユニーク制約 CREATE UNIQUE INDEX idx_users_email ON users(email, COALESCE(deleted_at, '9999-12-31'));

どれを選んでも「なぜこんなことをしているのか」を説明するコストが発生する。

外部キー制約との相性の悪さ

-- ordersはusersを参照する CREATE TABLE orders ( id UUID PRIMARY KEY, user_id UUID REFERENCES users(id), deleted_at TIMESTAMPTZ ); -- ユーザーを論理削除 UPDATE users SET deleted_at = NOW() WHERE id = $1; -- 問題: ordersからは削除されていないuserへの参照が残る -- deleted_at IS NULLでフィルタすると、関連データが取得できない SELECT o.*, u.name FROM orders o INNER JOIN users u ON o.user_id = u.id AND u.deleted_at IS NULL WHERE o.deleted_at IS NULL; -- → ユーザーが論理削除されると、その注文も見えなくなる(意図した動作?)

「削除されたユーザーの注文はどう扱うべきか」という問いに、論理削除は答えを持っていない。データベースが提供する整合性保証を、アプリケーションコードで再実装する羽目になる。

カスケード削除との相性が最悪 -- 物理削除用に設計されたスキーマ CREATE TABLE orders ( id UUID PRIMARY KEY, user_id UUID REFERENCES users(id) ON DELETE CASCADE ); -- 論理削除を導入すると... UPDATE users SET deleted_at = NOW() WHERE id = $1; -- → ordersは削除されない(CASCADEはDELETEにしか反応しない) -- → 「削除された」ユーザーの注文が残り続ける パフォーマンス問題

論理削除されたレコードが増えるほど、テーブルは肥大化する。パーシャルインデックスで対処できる。しかし、これも「論理削除を選んだがゆえの追加コスト」だ。

-- 10年運用したサービス -- 全レコード: 100万件 -- 有効レコード: 10万件 -- 削除済みレコード: 90万件 -- すべてのクエリが90万件のゴミをスキャンする可能性がある SELECT * FROM users WHERE email = 'test@example.com' AND deleted_at IS NULL; CREATE INDEX idx_users_email_active ON users(email) WHERE deleted_at IS NULL; テストの複雑化 #[tokio::test] async fn test_get_active_users() { // 有効なユーザーを作成 let active_user = create_user(&pool, "active@example.com").await; // 削除済みユーザーを作成 let deleted_user = create_user(&pool, "deleted@example.com").await; soft_delete_user(&pool, deleted_user.id).await; // テスト対象 let users = get_all_users(&pool).await; // 削除済みが含まれていないことを確認 assert!(!users.iter().any(|u| u.id == deleted_user.id)); } // このテストを書き忘れると、バグが本番に流出する // すべてのクエリに対して、このテストが必要 deleted_at vs is_deleted:どちらを選ぶべきか

論理削除の実装には2つの方式がある。

-- 方式1: タイムスタンプ(deleted_at) deleted_at TIMESTAMPTZ -- NULLなら有効、値があれば削除済み -- 方式2: ブールフラグ(is_deleted) is_deleted BOOLEAN DEFAULT FALSE -- falseなら有効、trueなら削除済み

deleted_at を推奨する理由:

  • 「いつ削除されたか」という情報が自動的に残る
  • 監査ログとして機能する
  • 「30日以上前に削除されたデータをアーカイブ」といった処理が書きやすい

is_deleted のメリット:

  • シンプルで直感的
  • インデックスが小さくなる(BOOLEAN vs TIMESTAMPTZ)
  • NULLの扱いを考えなくてよい(deleted_at IS NULL vs is_deleted = false)

私の経験では deleted_at が主流だ。削除日時の情報は運用・デバッグで頻繁に必要になる。ただし、削除日時が不要でパフォーマンスを最優先するなら is_deleted も選択肢になる。

RustのORMには論理削除サポートがない

RustのORMは、論理削除の組み込みサポートを持たない。

// SeaORM - 論理削除の組み込みサポートなし let users = User::find().all(&db).await?; // deleted_atのフィルタは手動で追加する必要がある let users = User::find() .filter(user::Column::DeletedAt.is_null()) .all(&db) .await?; // 毎回書く必要がある。書き忘れてもコンパイルは通る。 // Diesel - 同様に組み込みサポートなし let users = users::table .filter(users::deleted_at.is_null()) .load::<User>(&mut conn)?; // sqlx - 生SQLなので当然サポートなし let users = sqlx::query_as!(User, "SELECT * FROM users WHERE deleted_at IS NULL" ) .fetch_all(pool) .await?;

Rustは「暗黙の動作」より「明示的なコード」を好む文化がある。論理削除フィルタが自動適用されるのは便利だが、何が起きているか分かりにくくなる。そのため、RustのORMは意図的にこの機能を持たないとも解釈できる。

しかし、Dieselには diesel-softdelete というコミュニティcrateが存在する。

// diesel-softdelete の使用例 use diesel_softdelete::SoftDelete; // soft_find: findと同等だが、削除済みを自動除外 let user = users::table.soft_find(user_id).first(&mut conn)?; // soft_inner_join: JOINのON句に削除フィルタを適用 let posts = posts::table .soft_inner_join(users::table) .load(&mut conn)?;

SeaORMには同様のcrateは存在しない。現実として、Rustでは論理削除を自分で安全に実装する必要がある。本記事で紹介する6つのパターンは、この課題に対する解決策だ。

Linterで防げないのか

「WHERE句の書き忘れ、Linterで検出できないの?」という疑問は当然だ。結論から言うと、難しい。

SQLFluff(SQLリンター)の限界

SQLFluffでカスタムルールを書くことは可能だが、根本的な問題がある。

# .sqlfluff - カスタムルールの例 [sqlfluff:rules] # 「deleted_at IS NULL を含まないSELECTを警告」というルールを書きたい # しかし...
  • どのテーブルが論理削除対象か、Linterは知らない
  • JOINの場合、どのテーブルにフィルタが必要か判定できない
  • サブクエリの中まで追跡するのは複雑

www.sqlfluff.com

sqlx のコンパイル時チェックの限界

sqlxはコンパイル時にSQLの構文と型をチェックするが、「論理削除フィルタがあるか」はチェックしない。

// これはコンパイルが通る(deleted_at フィルタなし) let users = sqlx::query_as!(User, "SELECT * FROM users WHERE status = 'active'") .fetch_all(pool) .await?;

理論的には可能だが、コストが高い

Proc Macroで独自のクエリマクロを作れば、検出は可能だ。

// 理論上のカスタムマクロ soft_query_as!(User, "SELECT * FROM users WHERE status = 'active'") // → コンパイルエラー: "deleted_at IS NULL" が含まれていません

しかし、これを実装・保守するコストは高い。テーブルごとの論理削除設定、JOIN時の挙動、サブクエリの処理など、考慮すべきことが多い。

結論:Linterより「ミスできない設計」

Linterは「ミスを検出する」アプローチだ。しかし、論理削除の問題は「そもそもミスできない設計」で解決した方が確実だ。RLSやビューを使えば、アプリケーション側でフィルタを書き忘れても、データベースが守ってくれる。Linterで「書き忘れを検出する」より、「書き忘れても問題ない」設計の方が堅牢だ。

www.postgresql.org

なぜそれでも論理削除を選ぶのか

ここまで問題を挙げてきた。それでも論理削除が選ばれ続ける理由を以下に示す。

「削除」は本当に削除ではない

ビジネスの世界では、「削除」は「なかったこと」にすることではない。

経理: 「この取引、間違いだったので削除してください」 開発者: (物理削除を実行) 経理: 「監査が来たとき、削除した取引の履歴を見せてください」 開発者: 「...」

法規制、監査対応、コンプライアンス。データを完全に消すことが許されないケースは多い。

誤操作からの復旧 ユーザー: 「間違えて投稿を削除してしまいました。復旧できますか?」

物理削除なら「できません」。論理削除なら「できます」。

この違いはサポートコストとユーザー満足度に直結する。

関連データの整合性 -- ユーザーを物理削除すると... DELETE FROM users WHERE id = $1; -- 関連する注文履歴はどうする? -- ON DELETE CASCADE で連鎖削除?→ 売上データが消える -- ON DELETE SET NULL で孤児にする?→ 「誰の注文かわからない」データが残る

論理削除なら、関連データの整合性を保ったまま「削除扱い」にできる。

分析・デバッグ用途 -- なぜこのユーザーは退会したのか? SELECT * FROM users WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC; -- 削除前の状態を確認したい SELECT * FROM posts WHERE id = $1; -- 削除済みでも見れる

物理削除されたデータは、永遠に失われる。

論理削除の本当の問題

ここまでの問題点を整理すると、論理削除の本当の問題が見えてくる。

それは「削除」という概念を、データモデルとして表現していないことだ。

deleted_at カラムは、「このレコードは存在するが、存在しないことにしてほしい」という矛盾した状態を表現している。この矛盾が、すべての問題の根源だ。

まず代替手段を検討せよ

ここまで論理削除の問題を散々挙げてきた。では、どうすればいいのか。

答えは状況によって異なる。まず、自分がどの状況にいるかを確認しよう。

状況A: 新規設計の場合 論理削除を使わない選択肢がある。代替手段を検討すべきだ。

状況B: 既存システムの場合 すでに deleted_at が導入されたテーブルが100個あるなら、今日明日で変えられるわけがない。事故を防ぐ仕組みで覆うしかない。

本セクションでは状況Aの代替手段を、次のセクションでは状況Bの安全な実装パターンを解説する。

新規設計なら、より良い選択肢がある。

Archive テーブルパターン(推奨)

削除されたデータを別テーブルに移動する、最もシンプルな代替手段。

-- 本番テーブル(有効なデータのみ) CREATE TABLE users ( id UUID PRIMARY KEY, name VARCHAR(100), email VARCHAR(255) UNIQUE -- 一意制約が正常に機能 ); -- アーカイブテーブル(削除済みデータ) CREATE TABLE archived_users ( id UUID PRIMARY KEY, name VARCHAR(100), email VARCHAR(255), -- UNIQUEなし archived_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), archived_by UUID, archive_reason TEXT ); -- 削除時のトランザクション BEGIN; INSERT INTO archived_users (id, name, email, archive_reason) SELECT id, name, email, 'user_requested' FROM users WHERE id = $1; DELETE FROM users WHERE id = $1; COMMIT;

メリット:

  • 本番テーブルはシンプルなまま
  • 一意制約、外部キー制約が正常に機能
  • WHERE句地獄から解放
  • アーカイブテーブルは別ストレージに配置可能

デメリット:

  • 復元時にデータ移動が必要
  • スキーマ変更時に両テーブルの更新が必要
Temporal Tables(履歴テーブル)

SQL:2011で標準化された機能。PostgreSQL 9.2+、SQL Server 2016+、MariaDB 10.3+でサポート。

-- PostgreSQLでのTemporal Table(拡張機能を使用) CREATE TABLE users ( id UUID PRIMARY KEY, name VARCHAR(100), email VARCHAR(255), valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(), valid_to TIMESTAMPTZ NOT NULL DEFAULT 'infinity' ); -- 過去の状態を参照 SELECT * FROM users WHERE id = $1 AND valid_from <= '2024-01-15' AND valid_to > '2024-01-15';

メリット:

  • 変更履歴が自動的に保存される
  • 「誰が」「いつ」「何を」変更したか追跡可能
  • 通常のクエリには影響なし

デメリット:

  • テーブルサイズが急速に増大(高頻度更新テーブルでは問題)
  • 複数テーブル間の相関は追跡できない
Event Sourcing

状態ではなく「イベント」を保存する設計パターン。

#[derive(Debug)] pub enum UserEvent { Created { id: Uuid, name: String, email: String }, Updated { id: Uuid, name: Option<String>, email: Option<String> }, Deleted { id: Uuid, reason: String }, Restored { id: Uuid }, } // イベントを順番に適用して現在の状態を再構築 fn rebuild_user(events: &[UserEvent]) -> Option<User> { let mut user: Option<User> = None; for event in events { match event { UserEvent::Created { id, name, email } => { user = Some(User { id: *id, name: name.clone(), email: email.clone(), deleted: false }); } UserEvent::Deleted { .. } => { if let Some(ref mut u) = user { u.deleted = true; } } UserEvent::Restored { .. } => { if let Some(ref mut u) = user { u.deleted = false; } } // ... } } user.filter(|u| !u.deleted) }

メリット:

  • 完全な監査ログ(ビジネスコンテキスト含む)
  • 任意の時点の状態を再現可能
  • デバッグが容易

デメリット:

  • 学習コストが高い
  • 読み取りパフォーマンスの課題(スナップショットが必要)
  • 既存システムへの導入が困難
PostgreSQL パーティショニング

大量データの削除が必要な場合、パーティショニングが有効。

-- 月別パーティションテーブル CREATE TABLE events ( id UUID, created_at TIMESTAMPTZ NOT NULL, data JSONB ) PARTITION BY RANGE (created_at); -- パーティションを作成 CREATE TABLE events_2024_01 PARTITION OF events FOR VALUES FROM ('2024-01-01') TO ('2024-02-01'); CREATE TABLE events_2024_02 PARTITION OF events FOR VALUES FROM ('2024-02-01') TO ('2024-03-01'); -- 古いデータの削除: DELETEではなくDROP DROP TABLE events_2023_01; -- 瞬時に完了、VACUUMも不要

メリット:

  • 大量削除が一瞬(DROP TABLE)
  • VACUUM負荷なし
  • パーティションプルーニングで検索も高速

デメリット:

  • パーティションキーの設計が重要
  • 管理の複雑さ(pg_partman等のツール推奨)
選択フローチャート

Rustで論理削除を安全に実装する6つのパターン

代替手段を検討した上で、それでも論理削除が必要な場合。ここからは、Rustの型システムとPostgreSQLの機能を活用して、論理削除を安全に実装する具体的なパターンを紹介する。

パターン1:Newtype Patternで状態を型として表現する

最もRustらしいアプローチ。「有効なデータ」と「削除済みデータ」を別の型として定義する。

use chrono::{DateTime, Utc}; use sqlx::PgPool; use uuid::Uuid; /// 有効なユーザー(削除されていない) #[derive(Debug, sqlx::FromRow)] pub struct ActiveUser { pub id: Uuid, pub name: String, pub email: String, pub created_at: DateTime<Utc>, } /// 削除済みユーザー #[derive(Debug, sqlx::FromRow)] pub struct DeletedUser { pub id: Uuid, pub name: String, pub email: String, pub deleted_at: DateTime<Utc>, } impl ActiveUser { /// 有効なユーザーを1件取得 pub async fn find_by_id( pool: &PgPool, id: Uuid, ) -> Result<Option<Self>, sqlx::Error> { sqlx::query_as!( Self, r#" SELECT id, name, email, created_at FROM users WHERE id = $1 AND deleted_at IS NULL "#, id ) .fetch_optional(pool) .await } /// 論理削除を実行 pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<bool, sqlx::Error> { let result = sqlx::query!( r#" UPDATE users SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL "#, id ) .execute(pool) .await?; Ok(result.rows_affected() > 0) } }

メリット:

  • 通常のコードパスでは削除済みデータに触れることが型的に不可能
  • 削除済みデータへのアクセスが明示的になる
  • コンパイル時に安全性が保証される
パターン2:トレイトで共通インターフェースを定義する

複数のエンティティで論理削除を扱う場合、トレイトで共通化する。

use async_trait::async_trait; #[async_trait] pub trait SoftDeletable: Sized { type Id; async fn find_active(pool: &PgPool, id: Self::Id) -> Result<Option<Self>, sqlx::Error>; async fn all_active(pool: &PgPool) -> Result<Vec<Self>, sqlx::Error>; async fn soft_delete(pool: &PgPool, id: Self::Id) -> Result<bool, sqlx::Error>; async fn restore(pool: &PgPool, id: Self::Id) -> Result<bool, sqlx::Error>; } // ジェネリックな関数での利用 async fn list_active<T: SoftDeletable>(pool: &PgPool) -> Result<Vec<T>, sqlx::Error> { T::all_active(pool).await } パターン3:PostgreSQLビューで安全なデフォルトを作る

データベース側で「安全なデフォルト」を定義する。

-- マイグレーション: 有効データのみを返すビューを作成 CREATE VIEW active_users AS SELECT id, name, email, created_at, updated_at FROM users WHERE deleted_at IS NULL; -- パーシャルインデックスで検索を高速化 CREATE INDEX idx_users_active ON users(id) WHERE deleted_at IS NULL; impl ActiveUser { /// ビューから取得(削除済みは絶対に含まれない) pub async fn all(pool: &PgPool) -> Result<Vec<Self>, sqlx::Error> { sqlx::query_as!( Self, "SELECT id, name, email, created_at, updated_at FROM active_users" ) .fetch_all(pool) .await } }

メリット:

  • Rustコードでフィルタを忘れる心配がない
  • 他の言語やツール(psql、DBeaver等)からも安全
  • JOINでも自動的にフィルタが適用される
パターン4:行レベルセキュリティ(RLS)で強制フィルタリング

PostgreSQLのRLSを使って、データベースレベルで論理削除フィルタを強制する。

-- 行レベルセキュリティを有効化 ALTER TABLE users ENABLE ROW LEVEL SECURITY; -- デフォルトポリシー:削除済みは見えない CREATE POLICY users_active_only ON users FOR SELECT USING ( deleted_at IS NULL OR current_setting('app.include_deleted', true) = 'true' ); /// 通常のクエリ(削除済みは自動的に除外される) pub async fn get_all_users(pool: &PgPool) -> Result<Vec<User>, sqlx::Error> { sqlx::query_as!(User, "SELECT id, name, email FROM users") .fetch_all(pool) .await // RLSにより、deleted_at IS NULLのレコードのみが返される } /// 削除済みを含める場合(明示的な設定が必要) pub async fn get_all_users_including_deleted(pool: &PgPool) -> Result<Vec<UserWithStatus>, sqlx::Error> { let mut tx = pool.begin().await?; sqlx::query("SET LOCAL app.include_deleted = 'true'") .execute(&mut *tx) .await?; let users = sqlx::query_as!(UserWithStatus, "SELECT id, name, email, deleted_at FROM users") .fetch_all(&mut *tx) .await?; tx.commit().await?; Ok(users) } パターン5:リポジトリパターンで抽象化する

レイヤードアーキテクチャでの実装パターン。

#[async_trait] pub trait ReadRepository<T, Id> { async fn find(&self, id: Id) -> Result<Option<T>, RepositoryError>; async fn all(&self) -> Result<Vec<T>, RepositoryError>; async fn exists(&self, id: Id) -> Result<bool, RepositoryError>; } #[async_trait] pub trait WriteRepository<T, Id>: ReadRepository<T, Id> { type CreateInput; type UpdateInput; async fn create(&self, input: Self::CreateInput) -> Result<T, RepositoryError>; async fn update(&self, id: Id, input: Self::UpdateInput) -> Result<T, RepositoryError>; async fn delete(&self, id: Id) -> Result<bool, RepositoryError>; // 論理削除 }

リポジトリの実装内部で常に deleted_at IS NULL を適用することで、利用側はフィルタを意識する必要がなくなる。

パターン6:マクロで定型コードを削減する

毎回同じパターンを書くのは面倒なので、マクロで自動生成する。

macro_rules! impl_soft_deletable { ($struct:ident, table = $table:literal, id_type = $id_type:ty) => { impl $struct { pub async fn find_active(pool: &PgPool, id: $id_type) -> Result<Option<Self>, sqlx::Error> { // 実装 } pub async fn soft_delete(pool: &PgPool, id: $id_type) -> Result<bool, sqlx::Error> { // 実装 } pub async fn restore(pool: &PgPool, id: $id_type) -> Result<bool, sqlx::Error> { // 実装 } } }; } // 使用例 impl_soft_deletable!(Comment, table = "comments", id_type = Uuid);

パターン比較

パターン 型安全性 実装コスト DB依存 推奨シーン Newtype Pattern 高 中 低 型を重視するプロジェクト トレイト抽象化 高 中〜高 低 複数エンティティがある場合 ビュー 中 低 高 シンプルなCRUD、多言語環境 RLS 高 中 高 マルチテナント、厳格なセキュリティ リポジトリ 高 高 低 大規模プロジェクト、DDD マクロ 中 低 低 定型コードを減らしたい

運用のベストプラクティス

6つのパターンを紹介したが、どれか1つを選べば終わりではない。実際の運用では、これらを組み合わせて使う。そして、どのパターンを選んでも共通して守るべきルールがある。

すべての対策を講じた上で、それでも deleted_at 方式を選ぶなら、以下を徹底する。

必須チェックリスト □ ビューを作成し、アプリはビュー経由でアクセス □ パーシャルインデックスを作成 □ リポジトリパターンで抽象化 □ 削除済みデータのテストを全クエリに追加 □ 定期的なアーカイブ処理を実装 必ずビューを作る CREATE VIEW active_users AS SELECT * FROM users WHERE deleted_at IS NULL;

アプリケーションは active_users にしかアクセスしない。

必ずリポジトリパターンを使う pub trait UserRepository { async fn find(&self, id: Uuid) -> Result<Option<User>, Error>; // 常に有効のみ } 必ずテストを書く #[test] fn deleted_users_are_not_returned() { /* ... */ }

すべてのクエリに対して、このテストを義務化する。

必ずパーシャルインデックスを作る CREATE INDEX idx_users_email_active ON users(email) WHERE deleted_at IS NULL;

パフォーマンス劣化を最小限に抑える。

必ず定期的にアーカイブする -- 1年以上前に削除されたデータをアーカイブテーブルに移動 INSERT INTO archived_users SELECT * FROM users WHERE deleted_at < NOW() - INTERVAL '1 year'; DELETE FROM users WHERE deleted_at < NOW() - INTERVAL '1 year';

本番テーブルの肥大化を防ぐ。

おわりに

論理削除の本質は、「このレコードは存在するが、存在しないことにしてほしい」という矛盾だ。この矛盾を無視して deleted_at を追加すると、WHERE句地獄、認知コスト、バグの温床という形で跳ね返ってくる。

しかし、法規制・監査・誤操作復旧のため、論理削除が必要になる場面は確実に訪れる。そのとき、2つの選択肢がある。矛盾を解消する設計(Archiveテーブル、Event Sourcing)を選ぶか、矛盾を受け入れて型システムとデータベース機能で安全に管理するか。

本記事ではRustの型システムとPostgreSQLの機能を活用した安全な実装パターンを紹介した。ただし、データベース設計は文脈次第で最適解が変わる。ここで紹介した方法が唯一の正解ではないので、自分のプロジェクトの要件に照らし合わせて判断してほしい。

参考リンク

  • Avoiding the soft delete anti-pattern - 論理削除がアンチパターンになる理由
  • Soft Deletion Probably Isn't Worth It - 論理削除の代替手段の検討
  • The Day Soft Deletes Caused Chaos - 論理削除が引き起こした実際の障害事例
  • diesel-softdelete - DieselのためのSoft Delete拡張
  • Soft deletion with PostgreSQL - PostgreSQLでの論理削除実装
  • Beyond DELETE: Drop Partitions, Not Performance - パーティショニングによる削除最適化
  • Temporal Tables and Event Sourcing - 代替アプローチの比較

参考書籍

達人に学ぶDB設計徹底指南書 第2版

  • 作者:ミック
  • 翔泳社
Amazon

達人に学ぶSQL徹底指南書 第2版 初級者で終わりたくないあなたへ

  • 作者:ミック
  • 翔泳社
Amazon

失敗から学ぶRDBの正しい歩き方 Software Design plus

  • 作者:曽根 壮大
  • 技術評論社
Amazon

SQLアンチパターン 第2版 ―データベースプログラミングで陥りがちな失敗とその対策

  • 作者:Bill Karwin
  • オーム社
Amazon

センスの良いSQLを書く技術 達人エンジニアが実践している35の原則

  • 作者:ミック
  • KADOKAWA
Amazon

📎📎📎📎📎📎📎📎📎📎