Dress Code Advent Calendar 2025 の 15 日目の記事です。
Dress Code 株式会社で、プロダクト開発しながらアーキテクト、組織設計、採用、技術広報などなど担当しているかわうそです。
今日は「履歴」というデータを取り扱うことために考えていること・戦い方について整理してみたので、その内容を共有したいと思います。
履歴のデータはとても重要になる場合があり、履歴管理や監査証跡、時点復元、法令対応などで「過去の状態を正確に残したい」という要件が発生することがあります。
!この記事では、テンポラルデータモデル、Event Sourcing についてふれていますが、それぞれの詳細については解説していませんので、ご了承ください。
また、個人的な見解も含んだ内容になります。
間違いや指摘があればコメントいただけると嬉しいです。

履歴を残すのが難しい理由を、RDB 設計・テンポラルデータモデルの観点から整理してみたいと思います。
ほとんどの RDB で管理されているデータは「現在の状態(Current State)」だけを保存するように設計されています(しています)
-- 典型的なテーブルCREATETABLE orders( id UUIDPRIMARYKEY, customer_id UUID,statusVARCHAR(20),-- 'created', 'shipped', 'cancelled' ... total_amountDECIMAL, updated_atTIMESTAMP);このテーブルで「注文をキャンセルした」という履歴を残そうとすると、以下のような方法が考えられます。
-- 履歴テーブル(変更「前」の状態だけ残す)CREATETABLE order_before_history( history_idSERIALPRIMARYKEY, order_id UUID, before_statusVARCHAR(20), before_amountDECIMAL, changed_byVARCHAR(50), changed_atTIMESTAMPDEFAULTNOW());-- キャンセル処理BEGIN;INSERTINTO order_before_history(order_id, before_status, before_amount, changed_by)SELECT id,status, total_amount,'user777'FROM ordersWHERE id='123';UPDATE ordersSETstatus='cancelled', updated_at=NOW()WHERE id='123';COMMIT;「何に変わったか」が分からない
変更前の状態しか保存していないため、履歴テーブルだけを見ても「何に変わったのか」が分かりません。
-- 履歴テーブルを見ても...SELECT*FROM order_before_historyWHERE order_id='123';-- before_status = 'paid', before_amount = 15000 ...-- → これだけでは何に変わったのかは分からない時点復元が困難
「2025-01-03 時点での注文状態は?」という問いに答えるのが難しいです。
valid_to(有効終了時刻)がないので、どの履歴レコードがいつまで有効だったか分かりません。
履歴を時系列で並べて、手動で「変更前 → 変更後」を推測する必要があります。
変更理由・操作内容が残らない
なぜ変更したのか(キャンセル?金額訂正?)、どんな業務操作だったのか、これらのコンテキストが失われるため、監査証跡としては不十分になってしまいます。
連続した変更の追跡が複雑
A → B → C と 3 回変更された場合、以下のような履歴が残ります。
| changed_at | before_status | before_amount |
|---|---|---|
| 2025-01-01 | created | 10000 |
| 2025-01-02 | paid | 10000 |
| 2025-01-03 | shipped | 10000 |
この履歴から「2025-01-02 時点の状態」を導くには、「2025-01-01 の変更で created から何かに変わって、それが 2025-01-02 の変更で paid から何かに変わった...」と推論する必要があります。
CREATETABLE order_status_history( idSERIALPRIMARYKEY, order_id UUID, from_statusVARCHAR(20), to_statusVARCHAR(20), reasonVARCHAR(200), changed_byVARCHAR(50), changed_atTIMESTAMPDEFAULTCURRENT_TIMESTAMP);-- キャンセル処理BEGIN;INSERTINTO order_status_history(order_id, from_status, to_status, reason, changed_by)VALUES('123','paid','cancelled','お客様都合キャンセル','user777');UPDATE ordersSETstatus='cancelled', updated_at=NOW()WHERE id='123';COMMIT;ステータス以外の属性変更が追跡できない
この方式はstatus の変更だけを追跡しています。例えばtotal_amount が変更された場合、その履歴は残りません。
-- 金額だけ変更された場合...UPDATE ordersSET total_amount=9800WHERE id='123';-- → order_status_history には何も残らない!元テーブルと履歴テーブルの整合性が保証されない
2 つのテーブルを別々に更新するため、アプリケーションコードで「必ず両方更新する」ルールを徹底する必要があり、うっかり元テーブルだけ更新してしまうと履歴が残らなくなってしまいます。
履歴の正しさを検証できない
元テーブルの現在のstatus と、履歴テーブルの最新to_status が一致している保証もなく、履歴の連続性(ある行のto_status が次の行のfrom_status と一致するか)も保証されないため、履歴の正しさを検証できません。
「作成」と「削除」の表現が曖昧
注文が最初に作成されたとき、from_status はどの値になるべきか、注文が削除されたとき、to_status はどの値になるべきか、つまり、「作成」と「削除」を同じテーブルで表現するのは難しく、目的も曖昧になってしまいます。
時点復元は改善されるが、完全ではないfrom_status、to_status、changed_at があるので、方式 A よりは改善されています。
ただし、status 以外の属性(例:total_amount)の時点復元もできませんし、結局、全属性を時点復元するには別の仕組みが必要となります。
CREATETABLE order_temporal( order_id UUID,statusVARCHAR(20), total_amountDECIMAL, valid_fromTIMESTAMPNOTNULL,-- 業務上有効になった時刻 valid_toTIMESTAMP,-- NULL = 現在有効 created_atTIMESTAMPDEFAULTCURRENT_TIMESTAMP,PRIMARYKEY(order_id, valid_from));-- キャンセルしたいときBEGIN;-- 現在の有効レコードを終了させるUPDATE order_temporalSET valid_to=NOW()WHERE order_id='123'AND valid_toISNULL;-- 新しい状態をINSERTINSERTINTO order_temporal(order_id,status, total_amount, valid_from, valid_to)VALUES('123','cancelled',15000,NOW(),NULL);COMMIT;「現在の状態」を取得するクエリが複雑になる
シンプルだった SELECT が毎回時間条件付きになります。
アプリケーション全体でこの条件を書き忘れると、終了したレコードも取得されてしまいます。
-- 通常のテーブルならSELECT*FROM ordersWHERE id='123';-- テンポラルテーブルでは毎回これSELECT*FROM order_temporalWHERE order_id='123'ANDNOW()>= valid_fromAND(valid_toISNULLORNOW()< valid_to);さらに、関連テーブルもテンポラルにすると、「ある時点での注文とその明細」を取得するクエリがより複雑になります。
-- 「2025-01-03 時点での注文と明細」SELECT o.*, i.*FROM order_temporal oJOIN order_item_temporal iON o.order_id= i.order_id-- 時間範囲の重なりを考慮AND i.valid_from<COALESCE(o.valid_to,'9999-12-31')ANDCOALESCE(i.valid_to,'9999-12-31')> o.valid_fromWHERE o.order_id='123'AND'2025-01-03'>= o.valid_fromAND('2025-01-03'< o.valid_toOR o.valid_toISNULL)AND'2025-01-03'>= i.valid_fromAND('2025-01-03'< i.valid_toOR i.valid_toISNULL);更新では「終了 + INSERT」の 2 ステップが必要
必ずトランザクションで実行しないと不整合が起きてしまうし、アプリケーションコードも煩雑になります。
BEGIN;-- Step 1: 現在の有効レコードを終了UPDATE order_temporalSET valid_to=NOW()WHERE order_id='123'AND valid_toISNULL;-- Step 2: 新しい状態を INSERTINSERTINTO order_temporal(...)VALUES(...);COMMIT;誤入力の訂正で監査証跡が失われる
「実は過去に入力した金額が間違っていた」という場合、過去の行を直接 UPDATE することになり「元々何が記録されていたか」が消えてしまいます。
Valid Time(業務上の有効期間)だけでは「いつシステムがその情報を認識していたか」が分かりません。これを解決するには System Time も必要になり、Bi-temporal への対応が求められます。
「同時点で複数有効」を防ぐ制約が難しい
「同じ order_id で同じ時点に 2 つの有効なレコードがあってはいけない」という制約は、標準的な UNIQUE 制約では表現できません。
-- これでは不十分UNIQUE(order_id, valid_from)-- PostgreSQL なら EXCLUDE 制約が使えますが...EXCLUDEUSING gist( order_idWITH=, tsrange(valid_from, valid_to)WITH&&)Bi-temporal テーブルの各行は、通常 4 つのタイムスタンプ(または 2 つの期間)を持ちます。
この構造により、以下のような複雑な問いに答えることができるようになります。
「現在(System Time = Now) の知識に基づくと、先月(Valid Time = Last Month) の価格は 10,000 円である。
しかし、先週時点(System Time = Last Week) の知識では、先月(Valid Time = Last Month) の価格は 9,800 円であると認識していた。」
これで解決できるのでは?と思うかもしれませんが、実際に運用が始まるとしんどい問題が出てきます。
Bi-temporal にすると先ほどの履歴のデータ構造は以下のようになります。
CREATETABLE order_history( order_id UUID,statusVARCHAR(20), total_amountDECIMAL(12,2),-- Valid Time(業務上有効な期間) valid_fromTIMESTAMP(6)NOTNULL, valid_toTIMESTAMP(6),-- NULL = 現在も有効-- System Time(システムが認識した期間) system_fromTIMESTAMP(6)NOTNULL,-- システムがこの事実を記録した時刻 system_toTIMESTAMP(6),-- NULL = まだ有効(削除・訂正されていない)PRIMARYKEY(order_id, valid_from, system_from));例えば、注文が以下の流れで進んだとします。
| order_id | status | total_amount | valid_from | valid_to | system_from | system_to |
|---|---|---|---|---|---|---|
| 123 | created | 10000 | 2025-01-01 | NULL | 2025-01-01 | NULL |
| ... | ... | ... | ... | ... | ... | ... |
| 123 | created | 10000 | 2025-01-01 | 2025-02-01 | 2025-01-01 | NULL |
| 123 | cancelled | 10000 | 2025-02-01 | NULL | 2025-02-01 | NULL |
| order_id | status | total_amount | valid_from | valid_to | system_from | system_to |
|---|---|---|---|---|---|---|
| 123 | created | 10000 | 2025-01-01 | 2025-02-01 | 2025-01-01 | 2025-03-01 |
| 123 | cancelled | 10000 | 2025-02-01 | NULL | 2025-02-01 | 2025-03-01 |
| 123 | created | 9800 | 2025-01-01 | 2025-02-01 | 2025-03-01 | NULL |
| 123 | cancelled | 9800 | 2025-02-01 | NULL | 2025-03-01 | NULL |
つまり、1 つの訂正で複数行の更新・追加が必要になります。
「金額 10,000 円 → 9,800 円」という 1 つの訂正で、既存の 2 行を終了(system_to を設定)、新しく 2 行を INSERT しており、合計 4 行の操作が必要になります。訂正対象の事実が複数の状態遷移にまたがっている場合、全ての関連行を修正する必要があります。
また、「何が訂正されたか」の追跡が困難になります。
system_to で終了した行と、新しく INSERT した行の関連付けは暗黙的であり、「この新しい行は、どの行を訂正したものか」を後から追うのが難しく、さらに訂正理由(なぜ訂正が必要だったか)も記録されないです。
-- この2行の関係性が分からない|123| created|10000|...|2025-01-01|2025-03-01| ← 終了した行|123| created|9800|...|2025-03-01|NULL| ← 新しい行-- 「10000 → 9800 の訂正」という事実が明示されていないので推測する必要がある例えば、「2025-01-03 時点での全注文一覧」が欲しくなっただけで以下のようなクエリになってしまいます。
-- 欲しいもの:2025-01-03時点で「業務上もシステム上も有効だった」注文一覧SELECTDISTINCTON(order_id) order_id,status, total_amountFROM order_bitemporalWHERE'2025-01-03'>= valid_fromAND('2025-01-03'< valid_toOR valid_toISNULL)AND'2025-01-03'>= system_fromAND('2025-01-03'< system_toOR system_toISNULL)ORDERBY order_id, system_fromDESC;Bi-temporal にすると、紐づく先のテーブルも Bi-temporal にしないと整合性が取れなくなってしまいます。
注文明細も含めて先ほどの例を考えてみます。
注文テーブル(Bi-temporal)
| order_id | status | total_amount | valid_from | valid_to | system_from | system_to |
|---|---|---|---|---|---|---|
| 123 | created | 15,000 | 2025-01-01 | 2025-02-01 | 2025-01-01 | NULL |
| ... | ... | ... | ... | ... | ... | ... |
| 123 | created | 15,000 | 2025-01-01 | 2025-02-01 | 2025-01-01 | NULL |
| 123 | created | 10,000 | 2025-02-01 | NULL | 2025-02-01 | NULl |
| ... | ... | ... | ... | ... | ... | ... |
| 123 | created | 15,000 | 2025-01-01 | 2025-02-01 | 2025-01-01 | 2025-03-01 |
| 123 | created | 10,000 | 2025-02-01 | NULL | 2025-02-01 | 2025-03-01 |
| 123 | created | 14,500 | 2025-01-01 | 2025-02-01 | 2025-03-01 | NULL |
| 123 | created | 9,500 | 2025-02-01 | NULL | 2025-03-01 | NULL |
注文明細テーブル(Bi-temporal)
| item_id | order_id | product | price | valid_from | valid_to | system_from | system_to |
|---|---|---|---|---|---|---|---|
| 1 | 123 | 商品 A | 5,000 | 2025-01-01 | NULL | 2025-01-01 | NULL |
| 2 | 123 | 商品 B | 5,000 | 2025-01-01 | NULL | 2025-01-01 | NULL |
| 3 | 123 | 商品 C | 5,000 | 2025-01-01 | NULL | 2025-01-01 | NULL |
| ... | ... | ... | ... | ... | ... | ... | ... |
| 1 | 123 | 商品 A | 5,000 | 2025-01-01 | NULL | 2025-01-01 | NULL |
| 2 | 123 | 商品 B | 5,000 | 2025-01-01 | NULL | 2025-01-01 | NULL |
| 3 | 123 | 商品 C | 5,000 | 2025-01-01 | 2025-02-01 | 2025-01-01 | NULL |
| ... | ... | ... | ... | ... | ... | ... | ... |
| 1 | 123 | 商品 A | 5,000 | 2025-01-01 | NULL | 2025-01-01 | 2025-03-01 |
| 2 | 123 | 商品 B | 5,000 | 2025-01-01 | NULL | 2025-01-01 | NULL |
| 3 | 123 | 商品 C | 5,000 | 2025-01-01 | 2025-02-01 | 2025-01-01 | NULL |
| 1 | 123 | 商品 A | 4,500 | 2025-01-01 | NULL | 2025-03-01 | NULL |
(このデータだけを見てもどういう経緯でこうなったのかはわからないですよね 😂)
ということで、観点を変えて、変化(イベント)・Event Sourcing の観点から整理してみます。
ここまで見てきた方式や Bi-temporal などすべてに共通するのが、「状態(State)」を保存しようとしていることです。
状態中心の発想: 「注文の現在の状態は何か?」 → status = 'cancelled' 「過去の状態も残したい」 → 状態のスナップショットを複数保存状態とは「ある時点での結果」です。状態を保存するということは、結果だけを切り取って保存することになります。そのため、以下の情報が失われます。
履歴を残そうとすると、失われた情報を補うために追加のカラムやテーブルが必要になり、結果として設計が複雑化していきます。
この「状態中心」の発想を変えてみます。
イベント中心の発想: 「何が起きたか?」 → OrderCancelled(注文がキャンセルされた) 「状態を知りたい」 → イベントを順番に適用して現在の状態を導出イベントは「起きた事実」そのものです。
| 状態(State) | イベント(Event) |
|---|---|
| 結果 | 原因 |
| 変わりうる(Mutable) | 変わらない(Immutable) |
| 「今どうなっているか」 | 「何が起きたか」 |
| スナップショット | 事実の記録 |
先ほどの注文の例を、両方の視点で見てみます。
状態中心(Bi-temporal)の場合、「各時点での状態のスナップショット」を保存します。
| order_id | status | total_amount | valid_from | valid_to | system_from | system_to |
|---|---|---|---|---|---|---|
| 123 | created | 10000 | 2025-01-01 | 2025-02-01 | 2025-01-01 | 2025-03-01 |
| 123 | cancelled | 10000 | 2025-02-01 | NULL | 2025-02-01 | 2025-03-01 |
| 123 | created | 9800 | 2025-01-01 | 2025-02-01 | 2025-03-01 | NULL |
| 123 | cancelled | 9800 | 2025-02-01 | NULL | 2025-03-01 | NULL |
どの行がどう関連しているか、何が起きたのか、このデータだけでは分かりにくいです(再掲)
イベント中心の場合、「何が起きたか」をそのまま保存します。
| event_id | order_id | event_type | event_data | occurred_at | system_from |
|---|---|---|---|---|---|
| 1 | 123 | OrderCreated | {amount: 10000, by: "user123"} | 2025-01-01 | 2025-01-01 |
| 2 | 123 | OrderCancelled | {reason: "お客様都合", by: "user123"} | 2025-02-01 | 2025-02-01 |
| 3 | 123 | AmountCorrected | {before: 10000, after: 9800, reason: "入力ミス訂正"} | 2025-01-01 | 2025-03-01 |
※ occurred_at は業務上発生した時刻、system_from はシステムが記録した時刻
何が起きたか、なぜ起きたか、誰が起こしたか、すべて明示的に残すことができます。
では「注文 123 の現在の状態」を取得してみます。
SELECT order_id,status, total_amountFROM order_bitemporalWHERE order_id='123'AND valid_from<=NOW()-- 開始時刻が現在以前AND(valid_toISNULLOR valid_to>NOW())-- 終了時刻が未設定、または未来AND system_toISNULL;-- システム上、現在も有効(訂正されていない)| order_id|status| total_amount||-------- | --------- | ------------ ||123| cancelled|9800|方法 1:イベントをリプレイして導出する
// イベントを時系列で取得const events=await eventStore.getEvents("order-123");// イベントを順番に適用して現在の状態を導出const currentState= events.reduce((state, event)=>{switch(event.type){case"OrderCreated":return{ status:"created", amount: event.data.amount};case"OrderCancelled":return{...state, status:"cancelled"};case"AmountCorrected":return{...state, amount: event.data.after};default:return state;}},null);// → { status: 'cancelled', amount: 9800 }イベントが少なければ十分ですが、イベントが膨大になるとリプレイのコストが高くなります。
方法 2:Read Model(プロジェクション)を使う
現在の状態を別テーブル(Read Model)に保持しておき、イベント発生時に更新します。
-- Read Model(現在の状態を保持するテーブル)CREATETABLE order_current_state( order_id UUIDPRIMARYKEY,statusVARCHAR(20), total_amountDECIMAL, updated_atTIMESTAMP);-- 取得はシンプルSELECT*FROM order_current_stateWHERE order_id='123';Read Model はイベントから導出された状態のキャッシュです。いつでもイベントから再構築できるため、要件に応じて自由に設計できるのが特徴です。
// イベント発生時に Read Model を更新eventStore.on("OrderCancelled",async(event)=>{await db.query("UPDATE order_current_state SET status = $1, updated_at = $2 WHERE order_id = $3",["cancelled", event.recordedAt, event.orderId]);});履歴を扱う上で Event Sourcing はとても便利そうに見えますが、導入していくにはいろんな壁と戦っていく必要があります。
イベントをリプレイして状態を導出する方式は、イベント数が少なければ問題ありませんが、数万〜数十万件になると現実的ではなくなります。
const events=await eventStore.getEvents("order-123");// 数万件を超えてくるとリプレイのコストが高くなるconst state= events.reduce(applyEvent,null);解決策として、Snapshot と Read Model を導入することが考えられます。
// Snapshot があれば、そこからリプレイconst snapshot=await snapshotStore.getLatest("order-123");const newEvents=await eventStore.getEventsSince("order-123", snapshot.version);const state= newEvents.reduce(applyEvent, snapshot.state);ただし、Snapshot や Read Model を導入すると、システムの複雑性は増していきます。
Event Sourcing は「何が起きたか」を記録しますが、「いつからいつまで有効だったか」という期間情報は直接表現しません。
単純に「最新の状態」を持つ Read Model だけでは不十分で、期間情報を持った別の Read Model が必要になります。
-- 期間を扱う Read Model の例CREATETABLE product_validity_periods( product_id UUID, valid_fromTIMESTAMPNOTNULL, valid_toTIMESTAMP,statusVARCHAR(20),PRIMARYKEY(product_id, valid_from));Bi-temporal が必要だった理由は「訂正時に過去の認識(System Time)を残すため」でしたが、Event Sourcing では、イベントは不変で追記のみ、訂正もイベントとして記録され、「いつ記録されたか」はイベント自体に残っています。
つまり、イベントストアが システム時間(System Time)の役割を担っているので、Read Model は業務上の有効期間(Valid Time)だけを持てば十分です。
「ある時点の認識で状態を見たい」場合は、イベントストアに戻って、その時点までのイベントをリプレイすれば導出できます。
イベントは不変(Immutable)なので、一度保存したイベントのスキーマを変更することはできません。しかし、ビジネス要件はフェーズが進むにつれて変化していきます。
// v1: 初期のイベント{ type:'OrderCreated', amount:10000}// v2: 税込/税抜を分けたくなった{ type:'OrderCreated', amountExcludingTax:9091, tax:909}// v3: 通貨も持ちたくなった{ type:'OrderCreated', amountExcludingTax:9091, tax:909, currency:'JPY'}過去のイベント(v1)を読み込むとき、新しいコード(v3)で正しく処理するには、v1 から v3 に変換するロジック(Upcaster パターン) を用意する必要があります。
Upcaster パターンとは、主に Event Sourcing で使われる設計パターンで、過去に保存された古い形式のイベントを、現在の最新のイベント形式(スキーマ)に変換するための仕組みです。
(この記事を書く中でこのパターンの名前を知りました笑)
古いバージョンのイベントを新しいバージョンに変換するロジックを用意します。
const upcasters={ OrderCreated:{1:(event)=>({...event, amountExcludingTax: event.amount, tax:0}),2:(event)=>({...event, currency:"JPY"}),},};functionupcast(event){let result= event;for(let v= event.version; v<CURRENT_VERSION; v++){ result= upcasters[event.type][v](result);}return result;}上記以外に「過去のイベントを物理的に新しいスキーマに書き換える」アプローチもあります。
書き換えしてしまえば、アプリケーションは新しいスキーマだけを扱えばよいので、Upcaster パターンは不要になります。
ただし、Event Sourcing の原則(イベントの不変性)を破ることになりますし、監査証跡としての価値が下がる可能性があります。
イベントは不変ですが、「このイベントは間違いだった」というケースは現実に発生します。
「誤って注文をキャンセルしてしまった」「金額を間違えて入力した」などのケースがあります。
この問題に対応するためにCompensating Event(補償イベント) というものを導入する必要があります。「取り消し」や「訂正」も新しいイベントとして記録します。
// 誤ってキャンセルしてしまった → キャンセルを取り消すイベント{ type:'OrderCancellationReverted', reason:'誤操作', revertedBy:'admin001'}// 金額訂正{ type:'AmountCorrected', before:10000, after:9800, reason:'入力ミス'}ただし、「どこまで遡って訂正するか」「関連するイベントへの影響をどう扱うか」は慎重に設計しないと破綻してしまいます。
イベント発行から Read Model の更新は同期的に行われるわけではないため、タイムラグがあります。そのため、ユーザーが「保存したのに反映されていない」と感じるStale Read 問題 が発生します。
そのため、UI 側で楽観的更新(Optimistic Update)を行ったり、同期的な読み取りが必要なケースでは、イベントストアから直接リプレイすることで対応します。
割愛します...笑
他にも「イベント設計(粒度)」「Read Model の設計と更新ロジック」「Snapshot 戦略」「エラーハンドリング」など考えないといけないことはたくさんあります。
Event Sourcing は「履歴」という要件にはとても強力ですが、トレードオフは必ず発生します。
| メリット | デメリット |
|---|---|
| 完全な履歴が自動的に残る | 最新状態の取得に工夫が必要(Snapshot / Read Model) |
| 任意の時点の状態を復元できる | Read Model の設計は難しい |
| 「何が起きたか」が明示的 | イベントスキーマの Versioning への対応が必要 |
| 監査証跡として価値がある | 結果整合性への対応が必要 |
とはいえ、Bi-temporal のようなテンポラルデータモデルで運用し続けるのも難しいです。
少なくとも「履歴」という要件が厳しいときには、有効な手段として「Event Sourcing」を 1 つの選択肢として検討してみると良いかもしれません。
いろいろ話してきましたが、「Event Sourcing」を導入するときに一番大切なのは「やり切ると決めたら最後までやり切る」という気持ちです笑
バッジを受け取った著者にはZennから現金やAmazonギフトカードが還元されます。