じゃあ、おうちで学べる

本能を呼び覚ますこのコードに、君は抗えるか

論理削除という技術的負債、それでも僕たちは使い続ける

はじめに

「論理削除?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 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も不要

メリット:

デメリット:

選択フローチャート

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の機能を活用した安全な実装パターンを紹介した。ただし、データベース設計は文脈次第で最適解が変わる。ここで紹介した方法が唯一の正解ではないので、自分のプロジェクトの要件に照らし合わせて判断してほしい。

参考リンク

参考書籍