はじめに
「論理削除?deleted_atカラム追加すればいいでしょ」この一言から始まる地獄を、何度見てきただろうか。
最初は簡単に見える。カラムを1つ追加するだけ。しかし、その「簡単さ」こそが罠だ。
論理削除は技術的負債の温床だ。WHERE句への条件追加忘れ、認知コストの増大、テストの複雑化、パフォーマンス劣化。すべては「最初にドメインを考えなかった」ツケである。
しかし現実として、サービスを運用していくと論理削除が必要になる場面は確実に訪れる。
論理削除の本質は、「このレコードは存在するが、存在しないことにしてほしい」という矛盾だ。この矛盾を解消するか、受け入れて安全に管理するか。本記事ではその両方のアプローチを解説する。
なお、私はDBのスペシャリストではないので、ここで紹介する方法が唯一の正解というわけではない。あくまで一つのアプローチとして参考にしてほしい。データベース設計は文脈次第で最適解が変わるため、「この記事に書いてあったから」ではなく、自分のプロジェクトに合うかどうかで判断してほしい。
論理削除の何が問題なのか
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 NULLvsis_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の場合、どのテーブルにフィルタが必要か判定できない
- サブクエリの中まで追跡するのは複雑
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で「書き忘れを検出する」より、「書き忘れても問題ない」設計の方が堅牢だ。
なぜそれでも論理削除を選ぶのか
ここまで問題を挙げてきた。それでも論理削除が選ばれ続ける理由を以下に示す。
「削除」は本当に削除ではない
ビジネスの世界では、「削除」は「なかったこと」にすることではない。
経理: 「この取引、間違いだったので削除してください」 開発者: (物理削除を実行) 経理: 「監査が来たとき、削除した取引の履歴を見せてください」 開発者: 「...」
法規制、監査対応、コンプライアンス。データを完全に消すことが許されないケースは多い。
誤操作からの復旧
ユーザー: 「間違えて投稿を削除してしまいました。復旧できますか?」
物理削除なら「できません」。論理削除なら「できます」。
この違いはサポートコストとユーザー満足度に直結する。
関連データの整合性
-- ユーザーを物理削除すると... 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も不要
メリット:
デメリット:
- パーティションキーの設計が重要
- 管理の複雑さ(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 - 代替アプローチの比較