Movatterモバイル変換


[0]ホーム

URL:


DRESS CODE TECH BLOGDRESS CODE TECH BLOG
DRESS CODE TECH BLOGPublicationへの投稿
🕒

「状態」ではなく「変化(イベント)」を保存したい

に公開1件

はじめに

Dress Code Advent Calendar 2025 の 15 日目の記事です。

Dress Code 株式会社で、プロダクト開発しながらアーキテクト、組織設計、採用、技術広報などなど担当しているかわうそです。

今日は「履歴」というデータを取り扱うことために考えていること・戦い方について整理してみたので、その内容を共有したいと思います。

履歴のデータはとても重要になる場合があり、履歴管理や監査証跡、時点復元、法令対応などで「過去の状態を正確に残したい」という要件が発生することがあります。

!

この記事では、テンポラルデータモデル、Event Sourcing についてふれていますが、それぞれの詳細については解説していませんので、ご了承ください。

また、個人的な見解も含んだ内容になります。
間違いや指摘があればコメントいただけると嬉しいです。

概要

  • 「状態(Current State)」の保存は一見シンプルだけど、履歴要件が入ると破綻しやすい
  • Bi-temporal は強力な設計手法だが、クエリ・運用の複雑さが課題になりやすい
  • Event Sourcing は「変化(イベント)」を中心に据えることで、履歴が自然に残すことができる
  • ただし、Read Model や Versioning など、別の複雑さとのトレードオフがある

この記事の概要

「履歴」を残すって意外と難しい

履歴を残すのが難しい理由を、RDB 設計・テンポラルデータモデルの観点から整理してみたいと思います。

一般的な RDB っぽく設計すると破綻してしまう

ほとんどの RDB で管理されているデータは「現在の状態(Current State)」だけを保存するように設計されています(しています)

-- 典型的なテーブルCREATETABLE orders(  id            UUIDPRIMARYKEY,  customer_id   UUID,statusVARCHAR(20),-- 'created', 'shipped', 'cancelled' ...  total_amountDECIMAL,  updated_atTIMESTAMP);

このテーブルで「注文をキャンセルした」という履歴を残そうとすると、以下のような方法が考えられます。

  • 方式 A:変更前スナップショット方式
  • 方式 B:ステータス更新+別途「変更履歴テーブル」に INSERT
  • 方式 C:有効期間(Valid Time)を持たせるテンポラルテーブル方式

方式 A:変更前スナップショット方式

-- 履歴テーブル(変更「前」の状態だけ残す)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_atbefore_statusbefore_amount
2025-01-01created10000
2025-01-02paid10000
2025-01-03shipped10000

この履歴から「2025-01-02 時点の状態」を導くには、「2025-01-01 の変更で created から何かに変わって、それが 2025-01-02 の変更で paid から何かに変わった...」と推論する必要があります。

方式 B:ステータス更新+別途「変更履歴テーブル」に INSERT

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_statusto_statuschanged_at があるので、方式 A よりは改善されています。
ただし、status 以外の属性(例:total_amount)の時点復元もできませんし、結局、全属性を時点復元するには別の仕組みが必要となります。

方式 C:有効期間(Valid Time)を持たせるテンポラルテーブル方式

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 はこの悩みが解消されるのか?

Bi-temporal テーブルの各行は、通常 4 つのタイムスタンプ(または 2 つの期間)を持ちます。

  • Valid From / To → 現実世界でその事実が有効な期間
  • System From / To → データベースがその事実を知っていた期間

この構造により、以下のような複雑な問いに答えることができるようになります。

現在(System Time = Now) の知識に基づくと、先月(Valid Time = Last Month) の価格は 10,000 円である。
しかし、先週時点(System Time = Last Week) の知識では、先月(Valid Time = Last Month) の価格は 9,800 円であると認識していた。」

これで解決できるのでは?と思うかもしれませんが、実際に運用が始まるとしんどい問題が出てきます。

Bi-temporal は複雑になりやすい

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));

例えば、注文が以下の流れで進んだとします。

  1. 2025-01-01 に注文作成(10,000 円)
  2. 2025-02-01 にキャンセル
order_idstatustotal_amountvalid_fromvalid_tosystem_fromsystem_to
123created100002025-01-01NULL2025-01-01NULL
.....................
123created100002025-01-012025-02-012025-01-01NULL
123cancelled100002025-02-01NULL2025-02-01NULL
\hspace{5.5em}\hspace{5.5em}\hspace{5.5em}

誤入力訂正が入ると「過去の System Time を書き換える」必要がある

  1. 2025-03-01 に「実は金額は 9,800 円だった」と訂正
order_idstatustotal_amountvalid_fromvalid_tosystem_fromsystem_to
123created100002025-01-012025-02-012025-01-012025-03-01
123cancelled100002025-02-01NULL2025-02-012025-03-01
123created98002025-01-012025-02-012025-03-01NULL
123cancelled98002025-02-01NULL2025-03-01NULL
\hspace{5.5em}\hspace{5.5em}\hspace{5.5em}\hspace{5.5em}

つまり、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 にしないと整合性が取れなくなってしまいます。

注文明細も含めて先ほどの例を考えてみます。

  1. 2025-01-01 に注文作成(15,000 円、明細 3 件)
  2. 2025-02-01 に商品 C を削除(10,000 円、明細 2 件)
  3. 2025-03-01 に「実は商品 A の価格が 5,000 円ではなく 4,500 円だった」と訂正

注文テーブル(Bi-temporal)

order_idstatustotal_amountvalid_fromvalid_tosystem_fromsystem_to
123created15,0002025-01-012025-02-012025-01-01NULL
.....................
123created15,0002025-01-012025-02-012025-01-01NULL
123created10,0002025-02-01NULL2025-02-01NULl
.....................
123created15,0002025-01-012025-02-012025-01-012025-03-01
123created10,0002025-02-01NULL2025-02-012025-03-01
123created14,5002025-01-012025-02-012025-03-01NULL
123created9,5002025-02-01NULL2025-03-01NULL
\hspace{5.5em}\hspace{5.5em}\hspace{5.5em}\hspace{5.5em}

注文明細テーブル(Bi-temporal)

item_idorder_idproductpricevalid_fromvalid_tosystem_fromsystem_to
1123商品 A5,0002025-01-01NULL2025-01-01NULL
2123商品 B5,0002025-01-01NULL2025-01-01NULL
3123商品 C5,0002025-01-01NULL2025-01-01NULL
........................
1123商品 A5,0002025-01-01NULL2025-01-01NULL
2123商品 B5,0002025-01-01NULL2025-01-01NULL
3123商品 C5,0002025-01-012025-02-012025-01-01NULL
........................
1123商品 A5,0002025-01-01NULL2025-01-012025-03-01
2123商品 B5,0002025-01-01NULL2025-01-01NULL
3123商品 C5,0002025-01-012025-02-012025-01-01NULL
1123商品 A4,5002025-01-01NULL2025-03-01NULL
\hspace{5.5em}\hspace{5.5em}\hspace{5.5em}\hspace{5.5em}

(このデータだけを見てもどういう経緯でこうなったのかはわからないですよね 😂)

「変化(イベント)」を中心に考える

ということで、観点を変えて、変化(イベント)・Event Sourcing の観点から整理してみます。

状態中心の設計が難しくしている

ここまで見てきた方式や Bi-temporal などすべてに共通するのが、「状態(State)」を保存しようとしていることです。

状態中心の発想:  「注文の現在の状態は何か?」 → status = 'cancelled'  「過去の状態も残したい」    → 状態のスナップショットを複数保存

状態とは「ある時点での結果」です。状態を保存するということは、結果だけを切り取って保存することになります。そのため、以下の情報が失われます。

  • なぜ、その状態になったのか(理由・文脈)
  • どうやって、その状態になったのか(経緯・プロセス)
  • 誰が、その変化を引き起こしたのか(責任・トレーサビリティ)

履歴を残そうとすると、失われた情報を補うために追加のカラムやテーブルが必要になり、結果として設計が複雑化していきます。

「変化(イベント)」は事実そのもの

この「状態中心」の発想を変えてみます。

イベント中心の発想:  「何が起きたか?」 → OrderCancelled(注文がキャンセルされた)  「状態を知りたい」 → イベントを順番に適用して現在の状態を導出

イベントは「起きた事実」そのものです。

状態(State)イベント(Event)
結果原因
変わりうる(Mutable)変わらない(Immutable)
「今どうなっているか」「何が起きたか」
スナップショット事実の記録

「状態」と「イベント」で比較してみる

先ほどの注文の例を、両方の視点で見てみます。

  1. 2025-01-01 に注文作成(10,000 円)
  2. 2025-02-01 にキャンセル
  3. 2025-03-01 に「実は金額は 9,800 円だった」と訂正

状態中心(Bi-temporal)の場合、「各時点での状態のスナップショット」を保存します。

order_idstatustotal_amountvalid_fromvalid_tosystem_fromsystem_to
123created100002025-01-012025-02-012025-01-012025-03-01
123cancelled100002025-02-01NULL2025-02-012025-03-01
123created98002025-01-012025-02-012025-03-01NULL
123cancelled98002025-02-01NULL2025-03-01NULL
\hspace{5.5em}\hspace{5.5em}\hspace{5.5em}\hspace{5.5em}

どの行がどう関連しているか、何が起きたのか、このデータだけでは分かりにくいです(再掲)

イベント中心の場合、「何が起きたか」をそのまま保存します。

event_idorder_idevent_typeevent_dataoccurred_atsystem_from
1123OrderCreated{amount: 10000, by: "user123"}2025-01-012025-01-01
2123OrderCancelled{reason: "お客様都合", by: "user123"}2025-02-012025-02-01
3123AmountCorrected{before: 10000, after: 9800, reason: "入力ミス訂正"}2025-01-012025-03-01
\hspace{16.5em}\hspace{5.5em}\hspace{5.5em}

※ occurred_at は業務上発生した時刻、system_from はシステムが記録した時刻

何が起きたか、なぜ起きたか、誰が起こしたか、すべて明示的に残すことができます。

現在有効なデータを取得するには?

では「注文 123 の現在の状態」を取得してみます。

状態中心(Bi-temporal)の場合

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 は銀の弾丸ではない

履歴を扱う上で Event Sourcing はとても便利そうに見えますが、導入していくにはいろんな壁と戦っていく必要があります。

最新状態の取得が遅くなる

イベントをリプレイして状態を導出する方式は、イベント数が少なければ問題ありませんが、数万〜数十万件になると現実的ではなくなります。

const events=await eventStore.getEvents("order-123");// 数万件を超えてくるとリプレイのコストが高くなるconst state= events.reduce(applyEvent,null);

解決策として、Snapshot と Read Model を導入することが考えられます。

  • Snapshot
    • 定期的に「ある時点での状態」を保存しておき、そこからリプレイを開始する
  • Read Model
    • 現在の状態を別テーブルに保持し、イベント発生時に更新する(CQRS パターン)
// 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)だけを持てば十分です。

「ある時点の認識で状態を見たい」場合は、イベントストアに戻って、その時点までのイベントをリプレイすれば導出できます。

イベントスキーマの Versioning が難しい

イベントは不変(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 を採用すべきか?

Event Sourcing は「履歴」という要件にはとても強力ですが、トレードオフは必ず発生します。

メリットデメリット
完全な履歴が自動的に残る最新状態の取得に工夫が必要(Snapshot / Read Model)
任意の時点の状態を復元できるRead Model の設計は難しい
「何が起きたか」が明示的イベントスキーマの Versioning への対応が必要
監査証跡として価値がある結果整合性への対応が必要

とはいえ、Bi-temporal のようなテンポラルデータモデルで運用し続けるのも難しいです。

少なくとも「履歴」という要件が厳しいときには、有効な手段として「Event Sourcing」を 1 つの選択肢として検討してみると良いかもしれません。

おわりに

いろいろ話してきましたが、「Event Sourcing」を導入するときに一番大切なのは「やり切ると決めたら最後までやり切る」という気持ちです笑

DRESS CODE TECH BLOG により固定

DRESS CODEで情シス・人事労務・採用・総務といったWorkforce Management領域の課題を解決!

■ Dress Code株式会社、2024年9月設立、2025年4月正式創業
■ 企業経営・業務の摩擦問題を解消する「DRESS CODE」を開発中
■ 日本・インドネシア・ベトナム・タイ・シンガポールといったアジアを中心にグローバル展開中
■ プレシード及びシードラウンドにて14.1億円の資金調達完了

ご興味がある方はかわうそまたはぷーじまでご連絡ください。

かわうそ

Dress Code株式会社 / スタートアップ / テックリード / アーキテクト / 採用 / 技術広報 / 組織設計にも挑戦中 / 技術的負債と日々戦っています。

DRESS CODE TECH BLOG

DRESS CODEのProduct & Technologyチームによるテックブログです!プロダクトマネジメントからモデリング、アーキテクチャ、フロントエンド、バックエンド、SRE、セキュリティなどなど様々なテーマで情報を発信しています!

バッジを贈って著者を応援しよう

バッジを受け取った著者にはZennから現金やAmazonギフトカードが還元されます。

KvraKvra

Event Sourcingという考え方は聞いたことがありましたが、いざ実装となるとこんなに色々とハードルがあるのは知らなかったです。とても勉強になりました。

3

Dress Code株式会社 / スタートアップ / テックリード / アーキテクト / 採用 / 技術広報 / 組織設計にも挑戦中 / 技術的負債と日々戦っています。


[8]ページ先頭

©2009-2025 Movatter.jp