論理削除とDecoratorパターンで作る柔軟なリポジトリ設計
はじめに:論理削除だけで安心していませんか?
アプリケーション開発において、ユーザーが「削除」ボタンを押したとき、データベースから物理的に行を消してしまう(DELETE文の発行)ことはリスクになるケースがあります。
その際には論理削除がよく利用されます。
しかし、実際のプロダクション開発において、単に「削除フラグを立てる」だけでは不十分です。私たちは以下のような非機能要件にも対応しなければなりません。
- 耐障害性:DBが一瞬つながらない時、エラーにする?それともリトライする?
- パフォーマンス:毎回DBを見に行くと遅いのでキャッシュしたい
- 開発効率:テストのためにモックを使うなど切り替えたい場合や、開発初期はインメモリで動かしたい場合
これら全ての機能を1つのリポジトリクラス(例:PrismaUserRepository)に実装しようとすると、コードは肥大化し、条件分岐だらけのメンテナンス不能なスパゲッティコードになります。
本記事では、論理削除をベースにしつつ、Decoratorパターンを使ってこれらの機能を「RPGの装備」のように着脱可能にする設計を紹介します。
1. 論理削除とは
物理削除を行わず、データに「削除済み」というマークを付けることで、アプリケーション上は削除されたように振る舞わせる手法です。
仕組み:Tombstone(墓石)
データそのものは残しつつ、deletedAt(削除日時)というカラムに日付を入れることで「このデータは死んでいる」とみなします。このdeletedAtが入った状態を、分散システムの用語でTombstone(墓石)と呼びます。
本体(データ)はそこにありますが、墓石が立っているので生者(アプリ)からは見えなくなります。
論理削除のメリットとトレードオフ
導入する前に、メリットとデメリットを理解しておく必要があります。
| 項目 | 内容 |
|---|---|
| メリット | ・復元が可能:誤操作で消しても、deletedAtをnullに戻すだけで即座に復活(Undo)できます・分析の整合性:「先月の退会者数」などを集計する際、物理削除されていると過去を追えませんが、論理削除なら正確に追跡可能です |
| デメリット | ・クエリの複雑化:すべてのSELECT文にWHERE deleted_at IS NULLをつける必要があり、インデックス設計も工夫が必要です・プライバシー問題:GDPRなどの「忘れられる権利」に対応する場合、データが残っていること自体がリスクになります |
使い分けの基準:論理削除 vs 物理削除
何でもかんでも論理削除にするのは間違いです。データの性質に合わせて使い分けましょう。
論理削除すべきデータ:
- ユーザー情報 / 注文履歴 / 投稿データ:「間違えて消した!」という問い合わせが来る可能性があるもの。監査ログとして残す必要があるもの
物理削除すべきデータ:
- セッション情報 / 一時トークン:有効期限が切れたらゴミでしかないもの
- GDPR削除リクエスト対象:法的に「完全に消去」が求められるもの
- 古いログデータ:データ容量を圧迫するため、S3などのストレージにアーカイブした後はDBから消すべきもの
2. ドメイン層:純粋なインターフェースの定義
まず、特定のORMやDB製品に依存しない「契約(Interface)」を定義します。ここがドメインの核となります。
// src/domain/shared/BaseEntity.ts
// すべてのエンティティは「ID」と「墓石(論理削除日時)」を持つ
export interface BaseEntity {
id: string;
deletedAt: Date | null; // 論理削除されていない場合はnull
}
// src/domain/shared/IRepository.ts
// リポジトリは「保存・取得・論理削除」ができることだけを約束する
// 具体的な手段(SQLかメモリか)は問わない
export interface IRepository<T extends BaseEntity> {
save(entity: T): Promise<T>;
findById(id: string): Promise<T | null>;
softDelete(id: string): Promise<boolean>;
}
この時点では、MySQLもPrismaもRedisも登場しません。純粋なルールの定義です。
3. インフラ層:単純な実装(In-Memory)
次に、開発やテストで使えるシンプルな実装を作ります。ここで行うのは「データの読み書き」と「論理削除フラグの操作」だけです。
「本番はPostgreSQLを使うのに、メモリ実装を作る意味はあるのか?」と思うかもしれませんが、これには大きなメリットがあります。
- 詳細の遅延:DB選定が決まっていなくても開発を進められる
- 高速なテスト:DB接続のオーバーヘッドなしでロジックのテストができる。DDD(ドメイン駆動開発)やTDD(テスト駆動開発)と相性が良い
// src/infra/memory/InMemoryRepository.ts
import type { IRepository } from "../../domain/shared/IRepository.js";
import type { BaseEntity } from "../../domain/shared/BaseEntity.js";
export class InMemoryRepository<T extends BaseEntity> implements IRepository<T> {
private db: Map<string, T> = new Map();
async findById(id: string): Promise<T | null> {
const item = this.db.get(id);
// 論理削除されていたら null (存在しない) として扱う
if (!item || item.deletedAt !== null) return null;
return item;
}
async save(entity: T): Promise<T> {
this.db.set(entity.id, entity);
return entity;
}
async softDelete(id: string): Promise<boolean> {
const item = this.db.get(id);
if (!item || item.deletedAt !== null) return false;
// 物理削除せず、フラグだけ立てる(Tombstone)
// イミュータブルな更新として扱う
const updated = { ...item, deletedAt: new Date() } as T;
this.db.set(id, updated);
return true;
}
}
4. 機能の拡張:Decoratorパターンの導入
ここからが本題です。「キャッシュ機能」や「リトライ機能」をリポジトリに追加したい場合、どう実装すべきでしょうか?
アンチパターン:継承による組み合わせ爆発
安易にクラスの継承(extends)を使うと、地獄を見ることになります。
// ❌ 悪い例:継承で機能を足していく
class MySQLRepository { ... }
// キャッシュ機能を追加
class CachedMySQLRepository extends MySQLRepository { ... }
// リトライ機能を追加
class RetryingMySQLRepository extends MySQLRepository { ... }
// 両方必要になったら…?
class CachedAndRetryingMySQLRepository extends ...? // もう管理不可能
機能が増えるたびにクラスの組み合わせが指数関数的に増えていきます(組み合わせ爆発)。これでは、「今回はPostgresで、キャッシュなし、リトライありで動かしたい」といった柔軟な構成変更ができません。
解決策:Decoratorパターン
そこで、Decoratorパターンを使います。これは、リポジトリをマトリョーシカのように「ラップ」していく手法です。
RPGで例えるなら、素体となるキャラクター(リポジトリ)に「鉄の鎧(リトライ機能)」や「マント(キャッシュ機能)」を装備させていくイメージです。装備を変えるだけで、キャラクターの特性を自由に変更できます。
設計図
Decorator(装備品)は、リポジトリと同じインターフェース(IRepository)を実装しつつ、その内側に別のリポジトリを持っています。
実装例:2つの装備品
これらはsrc/infra/decorators/に配置し、ドメインのインターフェースに依存させます。
1. キャッシュの装備(CachedRepository)
「データ取得時にまずキャッシュを確認し、なければ内側のリポジトリに取りに行く」という振る舞いを追加します。
// src/infra/decorators/CachedRepository.ts
import type { IRepository } from "../../domain/shared/IRepository.js";
import type { BaseEntity } from "../../domain/shared/BaseEntity.js";
export class CachedRepository<T extends BaseEntity> implements IRepository<T> {
private cache = new Map<string, { data: T | null; expiry: number }>();
private readonly TTL = 5000; // 5秒のTTL
constructor(private inner: IRepository<T>) {}
async findById(id: string): Promise<T | null> {
const cached = this.cache.get(id);
// キャッシュが有効な場合
if (cached && cached.expiry > Date.now()) {
return cached.data;
}
// キャッシュミス:内側のリポジトリから取得
const item = await this.inner.findById(id);
// 次回のためにキャッシュ
this.cache.set(id, {
data: item,
expiry: Date.now() + this.TTL
});
return item;
}
async save(entity: T): Promise<T> {
const result = await this.inner.save(entity);
// 保存時は最新のresultでキャッシュを更新する
this.cache.set(entity.id, {
data: result,
expiry: Date.now() + this.TTL
});
return result;
}
async softDelete(id: string): Promise<boolean> {
const result = await this.inner.softDelete(id);
// 削除成功時はキャッシュを無効化
if (result) {
this.cache.delete(id);
}
return result;
}
}
2. リトライの装備(ResilientRepository)
エラーが起きても、それはネットワークなどの一時的な障害かもしれないため通信を繰り返す機能です。
ここで重要なのが指数バックオフ(Exponential Backoff)になります。DBが過負荷でダウンしている時に、全サーバーから即座にリトライを行うと、Thundering Herd問題が発生し、復旧しかけたDBにとどめを刺してしまいます(DBが完全にダウンする)。
「100ms待つ → 200ms待つ → 400ms待つ」と待機時間を徐々に増やすことで、アクセスを分散させ、システムの可用性を守ります。
// src/infra/decorators/ResilientRepository.ts
import type { IRepository } from "../../domain/shared/IRepository.js";
import type { BaseEntity } from "../../domain/shared/BaseEntity.js";
export class ResilientRepository<T extends BaseEntity> implements IRepository<T> {
constructor(
private inner: IRepository<T>,
private maxRetries = 3,
private baseDelay = 100
) {}
async save(entity: T): Promise<T> {
return this.withRetry(() => this.inner.save(entity));
}
async findById(id: string): Promise<T | null> {
return this.withRetry(() => this.inner.findById(id));
}
async softDelete(id: string): Promise<boolean> {
return this.withRetry(() => this.inner.softDelete(id));
}
// 共通リトライロジック
private async withRetry<R>(fn: () => Promise<R>): Promise<R> {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (error) {
attempt++;
// 最大リトライ回数に達したら諦める
if (attempt > this.maxRetries) {
throw error;
}
// 指数バックオフ:100ms, 200ms, 400ms, 800ms...
const delay = this.baseDelay * Math.pow(2, attempt - 1);
console.warn(`[Retry] Attempt ${attempt} failed. Waiting ${delay}ms before retry...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
}
5. 全体を組み立てる(Composition Root)
最後に、これらをmain.ts(Composition Root)で組み立てます。
この設計の良さは、コードのロジックを一切書き換えずに、インフラ構成(装備)を変更できる点です。
// src/main.ts
import { InMemoryRepository } from "./infra/memory/InMemoryRepository.js";
import { CachedRepository } from "./infra/decorators/CachedRepository.js";
import { ResilientRepository } from "./infra/decorators/ResilientRepository.js";
interface User extends BaseEntity {
name: string;
}
async function main() {
// 1. ベース
// ※ 将来例えばPrismaを利用した場合は、Prisma用のリポジトリを作成して、
// PrismaRepository()と書き換えれば切り替わる
const baseRepo = new InMemoryRepository<User>();
// 2. リトライ機能
// DBが不安定でも自動で再接続してくれるようになる
const resilientRepo = new ResilientRepository<User>(baseRepo);
// 3. キャッシュ機能
// 、読み込みを高速化する
const userRepo = new CachedRepository<User>(resilientRepo);
// --- アプリケーションのロジック ---
// 利用側は userRepo の中身が
// 「メモリなのかSQLなのか」「キャッシュされてるのか」を知る必要はない
// ただ IRepository として使うだけ
await userRepo.save({
id: "u_1",
name: "Taro",
deletedAt: null
});
const user = await userRepo.findById("u_1");
console.log(user); // キャッシュから取得される(2回目以降は高速)
await userRepo.softDelete("u_1");
const deletedUser = await userRepo.findById("u_1");
console.log(deletedUser); // null(論理削除されたので見えない)
}
main().catch(console.error);
装備の組み合わせは自由自在
例えば、開発環境ではキャッシュだけ、本番環境ではキャッシュとリトライの両方、といった使い分けも簡単です。
もし装備が多くなる場合は、Factoryパターンでまとめて生成するのも良いかもしれません
// 開発環境:キャッシュのみ
const devRepo = new CachedRepository(new InMemoryRepository<User>());
// 本番環境:リトライ + キャッシュ
const prodRepo = new CachedRepository(
new ResilientRepository(
new PrismaRepository<User>()
)
);
6. このパターンのメリット(まとめ)
この「論理削除 × Decorator」構成には以下のメリットがあります。
1. 関心の分離
- ドメイン層は「データの形(IDと墓石)」だけを気にする
- インフラ層は「保存方法」だけを気にする
それぞれのコードが混ざらないため、バグ修正や機能追加が容易です。
2. テスト容易性
CachedRepositoryのロジックをテストしたい時、わざわざRedisやDBを用意する必要はありません。InMemoryRepositoryをラップしてテストすれば良いからです。
逆にビジネスロジックの単体テストでは、Decoratorをすべて外してInMemoryRepositoryを直接使えば、高速でテストが回せます。
// テストコード例
describe('CachedRepository', () => {
it('should cache findById results', async () => {
const baseRepo = new InMemoryRepository<User>();
const cachedRepo = new CachedRepository(baseRepo);
await baseRepo.save({ id: 'u_1', name: 'Test', deletedAt: null });
// 1回目:DBから取得
await cachedRepo.findById('u_1');
// 2回目:キャッシュから取得(高速)
const result = await cachedRepo.findById('u_1');
expect(result?.name).toBe('Test');
});
});
3. 将来への拡張性
「Prismaをやめて生SQLにしたい」「Redisキャッシュを入れたい」といった変更が、特定のクラスを作成し、利用側(本記事だとmain.ts)で差し替えるだけで実現できます。
既存のコードは一切変更する必要がありません(SOLID原則のOCPに対応)
4. 段階的な導入が可能
アプリケーション開発で、最初からいきなり完璧なシステムを作る必要はありません。
- 最初は
InMemoryRepositoryだけで開発開始 - DB選定が決まったら
PrismaRepositoryを追加 - 負荷が高まってきたら
CachedRepositoryを導入 - 障害が気になり始めたら
ResilientRepositoryを追加
このように、必要になったタイミングで段階的に機能を追加していけます。
例) 今回のイテレーションではキャッシュ機能を実装、次回のイテレーションでPostgres用のリポジトリを作成する
発展
ここまでの設計で基本的な要件は満たせますが、実際のプロダクション環境ではさらに2つの重要な課題に直面します。
課題1:トランザクションはどうする?
リポジトリが分割されたことで、prisma.$transaction のようなトランザクション管理をどうやって最奥のリポジトリ(キャッシュやリトライ機能の奥にあるDB)まで通すのでしょうか? メソッドの引数に トランザクションクライアントをバケツリレーする設計は、「依存の方向がドメイン側に漏れる」「インターフェースがインフラ都合に支配される」という点で、クリーンアーキテクチャにおける「依存方向の制約」の観点で問題になります(境界の崩壊を引き起こしやすい)。
課題2:複雑な検索クエリは?
「30代かつプレミアム会員、または...」といった複雑な検索条件が増えるたびに、すべてのDecoratorにメソッドを追加していくと、クラスが爆発してしまいます。
これ以上の解説は記事が長くなりすぎてしまうため、別記事にて紹介させて頂きます。
結び
アプリケーション開発において、データ整合性を守る「論理削除」と、システムの安定性を守る「柔軟なリポジトリ構造」は車の両輪です。
フレームワークの機能に頼り切るのも手ですが、このようにデザインパターンを用いて自分たちで制御できる着脱可能なパーツとして作成・管理しておくと、システムの寿命と品質が格段に向上します。
また、この設計は単なる「綺麗なコード」ではなく、以下のような実務的な問題を解決します:
- DB移行時の影響範囲を最小化
- 新メンバーのオンボーディングを容易化(各レイヤーの責務が明確)
- 障害時の切り分けが容易(どのレイヤーで問題が起きているかが明確)
- A/Bテストなどでの部分的な構成変更
Discussion