論理削除と物理削除、業務システムではどちらを選ぶか

業務システムの設計でほぼ必ず議論になるのが「削除は論理削除(deleted_atカラムでフラグを立てる)にするか、物理削除(行を本当に消す)にするか」です。「監査要件があるなら論理削除」という定石をそのまま採用しがちですが、全テーブルを物理削除で統一し、deleted_atのようなカラムを1つも持たない設計も十分成立します。監査要件がないわけではなく、別の仕組みでそれを満たしていればよいからです。この分業のさせ方に、削除方式を考える上での実用的な判断基準が見えます。

削除はすべてdb.delete()

商品・仕入先・顧客・発注・入荷・出荷——業務システムのあらゆる削除処理を、例外なく行の物理削除として実装するケースを考えます。

// services/product.ts
export async function deleteProduct(ctx: ServiceCtx, id: string) {
  const target = await ctx.db.query.products.findFirst({ where: eq(schema.products.id, id) });
  await ctx.db.delete(schema.products).where(eq(schema.products.id, id));
  await logAudit({
    db: ctx.db,
    user_id: ctx.user.id,
    user_name: ctx.user.name,
    action: 'delete',
    target_type: 'product',
    target_id: id,
    target_label: target ? `${target.code} ${target.name}` : id,
  });
}

「削除したという事実と、削除された対象が何だったか」は、deleted_atフラグではなく、削除アクションの直前にlogAudit()へ渡す監査ログに委ねています。ここで重要なのは、監査ログに保存しているのは行全体のスナップショットではなく、target_labelという人間が識別できる程度の要約(商品コード+商品名など)だけだという点です。「これは復元用のバックアップではなく、あくまで“誰が・いつ・何を消したか”という履歴の記録である」という割り切りがうかがえます。

外部キー制約で「消してよい削除」と「消してはいけない削除」を線引きする

物理削除で最も怖いのは、参照されているデータを消してしまい整合性が壊れることです。これはテーブルごとの外部キー制約のonDelete設定で線引きできます。

// db/schema.ts
category_id: text('category_id').references(() => productCategories.id, { onDelete: 'set null' }),
// カテゴリを消しても商品は残る。ただしカテゴリ参照は外れる

product_id: text('product_id').references(() => products.id, { onDelete: 'cascade' }),
// 発注明細は、明細だけを取り出して個別に生き残る意味がないので発注と運命を共にする

「参照先が消えても実害がない関係」はset nullで緩く外し、「親が消えたら子も一緒に消えて当然の関係」はcascadeで追随させる。この設定を怠ると、物理削除は簡単に整合性を壊す危険な操作になりますが、リレーションごとに明示的に設計しておけば安全に運用できます。

業務上「消えると困る」ものは、そもそも削除させない

もう一段踏み込んで、発注データの扱いを考えてみます。

export async function deletePurchaseOrder(ctx: ServiceCtx, id: string) {
  const order = await ctx.db.query.purchaseOrders.findFirst({ where: eq(schema.purchaseOrders.id, id) });
  if (order.status !== 'draft' && order.status !== 'cancelled') {
    return fail(400, { error: 'Only draft or cancelled orders can be deleted' });
  }
  await ctx.db.delete(schema.purchaseOrders).where(eq(schema.purchaseOrders.id, id));
  // ...
}

発注はステータスがdraft(下書き)かcancelled(取消済み)でなければ削除できません。つまり、入荷が発生し在庫に影響を与えた発注(orderedreceived)は、物理削除の対象から最初から除外されています。もし取り消したければ、削除ではなくステータスをcancelledに変える操作を使うことになり、レコード自体はテーブルに残り続けます。

これは実質的に、発注という1つのテーブルの中だけで、statusカラムが論理削除に近い役割を部分的に肩代わりしている状態です。ただし全レコードに一律のdeleted_atを持たせるのではなく、「業務的な結果(在庫が動いた)を伴うレコードだけ」を対象に、既存のステータスの延長として扱っている点が違います。

判断基準を一般化する

ここから見えるのは、「論理削除か物理削除か」を全テーブル一律で決める必要はないということです。実際には、テーブルごとに次の3つの問いに答えることで、方式が自然に決まります。

  1. 削除の事実と対象の識別情報さえ残ればよいか、それとも行全体を後から復元できる必要があるか —— 前者なら監査ログへの要約記録で足り、物理削除で問題ありません。後者(例えば削除した注文をワンクリックで元に戻したい、といった要件)が必要なら、行そのものを保持する論理削除が必要になります。
  2. 削除によって他のレコードの整合性が壊れる関係か —— cascadeset nullrestrictのどれで解決できるかを、リレーションごとに検討します。ここで解決できないなら、そもそも物理削除自体を避けるべきサインです。
  3. そのレコードはすでに業務上の結果(在庫の増減、金銭のやり取りなど)を発生させているか —— 発生させているなら、そもそも「削除」という操作自体を許可すべきではなく、状態遷移(取消・キャンセル)で表現すべきです。

「監査要件があるから全テーブルにdeleted_atを持たせる」という判断は、実はコストの割に解決している問題が曖昧なことが多く、一覧・検索クエリすべてにWHERE deleted_at IS NULLを書き漏らさないようにする負債も抱え込みます。監査ログ・外部キー制約・ステータス管理という3つの仕組みで削除にまつわる要件を分解できるなら、deleted_atという4つ目の仕組みを持ち込まずに済む、という選択肢は十分に現実的です。