14
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

こんにちは!
株式会社ブレインパッドの @y-tsukasa です!

はじめに ♤

みなさんはババ抜きを知っていますか?
ババ抜きはそのルールのわかりやすさから老若男女問わず楽しむことができるトランプを用いたゲームの一種です。

以下のようなルールが一般的だと思います。

  1. ジョーカー(Joker) を1枚だけ入れたトランプをよく切る
  2. なくなるまでトランプを 参加者(Player) 全員に1枚ずつ配る (参加者が持つトランプの集まりを 手札(hands) と呼びます)
  3. 参加者の手札に ランク(Pip)1 の重複ペアがある場合、重複がなくなるまでそれらを手札から除きます
  4. 参加者同士で輪を作り、任意の方法で最初の 手番(turn) を担う参加者を決めます
  5. 手番の人は左隣にいる参加者の手札から 任意に1枚取り自分の手札に加えます。この手順によって手札にランクの重複ペアが生まれた場合、それらを手札から除きます。さらに、手番を左隣の参加者に移します
  6. 4,5の手順を繰り返し、手札がなくなった人は 勝者(winner) とします
  7. ジョーカーが1枚だけ手札にある最後の参加者が現れた時、その参加者を敗者としてゲームが終了します

せっかくなので、こんなに面白いババ抜きを世界中の人々が遊べるようにしたいと考えた私はババ抜きをWebアプリとして楽しめる BaaS (Babanuki As A Service) をリリースしました2

image.png

左隣の参加者が不正をして私の手札がジョーカー1枚になって負けたので調査して欲しい。

アプリを公開して早2時間、表題のような問い合わせがきてしまいました。

DB上にはゲーム毎の勝者と敗者しか保持しておらず、問い合わせにあるような不正がどのように発生したか・そもそも不正はあったのか分かりません。

問い合わせには答えられず、ユーザーにはお詫びの連絡をするしかありませんでした。

このことがきっかけで信用を失いサービスは泣く泣く終了することとなりました...

イベントソーシング (Event Sourcing)

さて、なぜ問い合わせに答えられなかったのでしょうか。

原因はデータの持ち方です3

なじみの深いCRUDは、現在の値に対して上書きし続けるため、基本的にDB上にはデータの最終的な状態のみが保持されています。
そのため「誰が・いつ・何を」という過程の情報はもうこの世に存在せず、そもそも設計段階から詰んでいました。

この問題を根本から解決するのが イベントソーシング (Event Sourcing) というデータ永続化のパターンです。
イベントソーシングでは、現在の状態を保持するのをやめて、起きた出来事(Event) を順番に積み上げます。

イベントソーシングの非常にわかりやすい例として、銀行の通帳があります。
通帳は残高(現在の値)を上書きするのではなく、入金・出金という出来事を記録しています。今の残高が知りたければ出来事を通帳の先頭から全て遡れば良いというわけです。

image.png

もし、ババ抜きアプリで起きた出来事の列が残っていればそれを先頭から 再生(rehydrate) するだけで、現在の状態はもちろん、途中の状態でさえも再現できたでしょう。さながらタイムトラベルです。

とはいえ、「イベントを残す」 というのは以下のような難しさに直面します。

  • 出来事の列から、現在の手札をどうやって復元するのか?
  • 「引く(Draw)」というプレイヤーの操作から、起こるべきイベントをどうやって決めるのか?

Deciderパターン

イベントソーシングをこの2つの疑問ごと綺麗に解決するのが、Deciderパターンです。
Deciderパターンは Jérémie Chassaing氏が"Functional Event Sourcing Decider"にて発表した関数型プログラミングでイベントソーシングを扱う方法です4

原文を読んでいただくのが一番ですが、簡単にDeciderパターンについて説明してみます。

System

任意のシステムについて考えるとき、たいてい入力と出力があって、時刻・環境変数といった外部の状態に依存します。また、システムはそれまでに何が起きたかにも依存します。

これらのシステムが依存する要素を切り離したサブシステムを用いてシステムを作ることを考えます。サブシステムはシステムがやりたいこと(ドメイン)を記述したものになります。
image.png

サブシステムというのはAction(時刻・環境変数等も含むシステムに対する入力)とState(状態)を受け取って、出力と更新後の状態を返す関数だとみることができます。

function subsystem(action: Action, state: State): [State, Output[]] {
    ...
}

CommandとEvent

サブシステムは先ほど述べたActionと呼ばれるトリガーによって呼び出されます。このActionは Command(システムに対する意図した変更の要求) としてとらえることができます5

Commandの例としては「商品を注文する」「送金する」などが相当し、それが成功するかどうかは別としてシステムに影響を与える可能性がある行為です。

このCommandによって何かがシステムに起きます。この起きた出来事を表現したものをEventといいます。

Eventは「商品が注文された」「送金が完了した」といった既に生じた具体的な事実が相当し、この事実は変えることができない、というものです。

decide

CommandとEventを使って、ビジネスルールの適用はdecideという関数で表すことができて、そのシグネチャは function decide(command: Command, state: State): Event[] になります。
image.png

つまり、decideは「ある状態の時に、コマンドを適用すると次のイベント列を生成する」というビジネスロジックを書き下したものになります。そしてdecideは起きうるべきイベント列を出力するだけなので、ここに一切の副作用は介在しません

evolve

ビジネスロジックの適用はdecideでできるようになりましたが、このままではシステムの状態を一向に変化させることができません。
なので、今の状態と既に起きた事実(Event)からどのような状態になるべきかを決める関数evolveを定義します6

function evolve(state: State, evnet: Event): State

decideの適用時点で「ビジネスロジックの適用」は完了しているので、evolveの実装はたいてい非常にシンプルになります。(リストへの要素の追加、フラグのスイッチ、識別子の変更程度)

initialState

最初のCommandを処理する場面を考えてみます。decideにはCommandと一緒にStateを渡す必要がありますが、まだ何も起きていない最初の段階では、渡すべきStateがありません。evolveについても同様で、最初のEventを適用するときの入力となるStateが存在しません。

そこで、何も起きていない時点の状態をあらかじめ定義しておきます。これを initialState(初期状態) と呼びます。

initialStateは明示的に定義しておく必要があります。電球のOn/Offや残高0の口座のように、後から再び同じ状態に戻りうる値をそのまま使うこともあります。

一方で、最初のEventが不可逆な場合もあります。例えばカードゲームでは、ゲーム開始前の「まだ始まっていない状態」は、一度ゲームが始まると二度と戻ってきません。こうした初期状態は「まだ何も処理していない」ことさえ表せれば十分なので、直和型を使うと素直に表現できます。

type State =
  | { tag: "NotStarted" }
  | { tag: "Started"; topCard: Card };

const initialState: State = { tag: "NotStarted" };

Deciderとは

ここまでで、Deciderパターンを構成する要素が出揃いました。Deciderとは、これらをひとまとめにしたものを指します。

  • すべてのCommandを表すCommand
  • すべてのEventを表すEvent
  • 取りうるすべての状態を表すState
  • 何も起きていない時点の状態initialState
  • CommandとStateからEvent列を返すdecide関数
  • StateとEventから次のStateを返すevolve関数
  • Stateが終了状態かどうかを返すisTerminal関数7

TypeScriptで型として表すと、次のようになります。

type Decider<State, Commnd, Event> = {
  decide: (command: Command, state: State) => Event[];
  evolve: (state: State, event: Event) => State;
  initialState: State;
  isTerminal: (state: State) => boolean;
};

Deciderは、Application層とドメインの間に立つ概念的なインターフェースだとみることができます。ドメインのロジックをDeciderとして書いておけば、それをメモリ上で動かすのか、データベースに状態を保存するのか、イベントストアに保存するのかといった永続化の方法は、ドメインのコードを一切変えずに後から選ぶことができます。

ババ抜きをドメインモデリングする

抽象的な話を展開されて退屈だったと思いますが、ここからは実際にDeciderパターンを使ってババ抜きを作ってみましょう!

ドメインを型に起こす

まずはババ抜きのルールを思い出します。ジョーカー1枚を含む53枚を全員に配り、配られた時点で手札にある同じランクのペアは捨てます。あとは手番のプレイヤーが左隣から1枚引き、引いてペアができたら捨てる、を繰り返します。手札がなくなった人はあがり、最後にジョーカーだけが残った1人が敗者です。

このルールに登場する「もの」を型に起こしていきます。

カードは、ババ抜きではランクが一致すればペアなのでスートは考えなくてよいですね。
ジョーカーか、ランクを持つ数札(Pip)かの2択なので、判別共用体で表します。

export type PlayerId = number;
export type Rank = number;

export type Card = { tag: 'Joker' } | { tag: 'Pip'; rank: Rank };

各プレイヤーの手札は「プレイヤー番号 → カードの列」の対応なので、Recordにします。

export type Hands = Record<PlayerId, Card[]>;

そして State(状態) です。手札・手番・あがった人・ゲームの進行状況をまとめて持ちます。

export type Status = 'NotStarted' | 'Playing' | 'Ended';

export interface GameState {
  hands: Hands;
  turn: PlayerId;       // いまの手番
  winners: PlayerId[];  // あがった人(新しい順に先頭へ積む)
  status: Status;
}

次に Command(意図) です。ババ抜きでプレイヤーがやりたいことは「ゲームを始める」「カードを引く」の2つです。

export type Command =
  | { tag: 'Start'; dealtHands: Hands }   // 配り終えた手札を渡す
  | { tag: 'Draw'; drawIndex: number };   // 左隣の index 番目を引く

ここがちょっとしたテクニックになっていて、シャッフルや「どの位置を引くか」といったランダム性を、Commandの中に値として持たせています。 配る順番をシャッフルした結果はStartdealtHandsとして、引く位置はDrawdrawIndexとして渡ってきます。

乱数という「外の世界」はApplication層側(シャッフルや配札)で先に消費して具体的な値にしてしまい、ドメインのdecideには確定済みの値だけを渡します。こうすることで、decideは乱数に一切触れない純粋な関数のままでいられます。

最後にババ抜きで起きうる Event(事実) です。Commandの結果として実際に起きたことを、過去形で並べます。

export type Event =
  | { tag: 'Started'; dealtHands: Hands } // 「ゲームが始まった」
  | { tag: 'Drawn'; from: PlayerId; to: PlayerId; card: Card } // 「カードが引かれた」 
  | { tag: 'PairDiscarded'; who: PlayerId; rank: Rank } // 「ペアが揃ったので捨てた」
  | { tag: 'Won'; player: PlayerId } // 「勝利した」  
  | { tag: 'GameEnded'; loser: PlayerId }; // 「ゲームが終了した」

Deciderを実装する

型が揃ったので、いよいよdecideevolveを書きます。土台となる汎用Deciderのインターフェースはこうです。

export interface Decider<State, Command, Event> {
  decide: (command: Command, state: State) => Either<string, Event[]>;
  evolve: (state: State, event: Event) => State;
  initial: State;
}

先ほどのDeciderの定義とほぼ同じですが、実装上の都合が2点あります。

  • decideの戻り値がEvent[]ではなくEither<string, Event[]>になっています。これは不正なコマンドを例外ではなくとして失敗で返すためのものです。成功ならright(events)、失敗ならleft("エラーメッセージ")を返します
  • ここでは簡単のためisTerminalを省いています。終局はState側のstatus: 'Ended'GameEndedイベントで表現しています

initialStateは、何も配られていない・誰の手番でもない・NotStartedなStateです。

export const initialState: GameState = {
  hands: {},
  turn: 0,
  winners: [],
  status: 'NotStarted',
};

evolveを実装する

evolveから先に書きます。evolveは起きてしまったEventを、淡々とStateへ反映するだけの関数なので、判断は一切しません。Eventのtagごとに分岐して、対応する状態更新を書き下すだけです。

function evolve(state: GameState, event: Event): GameState {
  switch (event.tag) {
    case 'Started':
      return {
        hands: event.dealtHands,
        turn: firstActivePlayer(event.dealtHands),
        winners: [],
        status: 'Playing',
      };
    case 'Drawn': {
      // from から1枚抜いて、to の先頭へ足す
      const afterRemove = adjustHand(state.hands, event.from, (hand) =>
        deleteOne(hand, event.card),
      );
      const handsAfterDraw = adjustHand(afterRemove, event.to, (hand) => [
        event.card,
        ...hand,
      ]);
      return {
        ...state,
        hands: handsAfterDraw,
        turn: nextActivePlayer(event.to, handsAfterDraw),
      };
    }
    case 'PairDiscarded': {
      // 同じランクを2枚(=1ペア)だけ取り除く
      const removeOnePair = (hand: Card[]) =>
        deleteOne(deleteOne(hand, Pip(event.rank)), Pip(event.rank));
      return { ...state, hands: adjustHand(state.hands, event.who, removeOnePair) };
    }
    case 'Won':
      return {
        ...state,
        hands: deleteHand(state.hands, event.player),
        winners: [event.player, ...state.winners],
      };
    case 'GameEnded':
      return { ...state, status: 'Ended' };
    default:
      return assertNever(event);
  }
}

どの分岐も「手札を移す」「ペアを取り除く」「フラグを立てる」程度で、非常にシンプルな実装になっていると思います。誰が勝ったか・引けるかといったビジネスルールの判断はここには出てきません。それらはすべて、これらのEventを生み出した時点、つまりdecideで決定されているからです。

(firstActivePlayer/nextActivePlayerは、手札が残っている在席プレイヤーを番号順・円環状にたどる補助関数です。空札になった人や退場した人は自動的に飛ばします)

なお、...stateで残りのフィールドをコピーしているとおり、evolveは元のStateを書き換えず新しいStateを返すimmutableな実装になっています。

decideを実装する

最後に、このパターンの主役であるdecideを作っていきます。Commandと現在のStateを受け取り、起きるべきEvent列を返します。Commandのtagで分岐していきましょう。

Start(ゲームを始める)は単純です。まだ始まっていなければ、Startedイベントと、配られた時点で既にできているペアのPairDiscardedを返します。

case 'Start': {
  if (state.status !== 'NotStarted') return left('既にゲームは進行中です');
  const started: Event = { tag: 'Started', dealtHands: command.dealtHands };
  return right([started, ...pairDiscards(command.dealtHands)]);
}

pairDiscardsは、手札を見て捨てるべきペアをPairDiscardedイベントの列に変換するヘルパー関数です(同じランクが4枚あれば2ペア=2イベント、というように数えます)。

問題はDraw(カードを引く)です。「1枚引く」という1つの行為から、ペアができて捨てる → 手札が尽きてあがる → 残り1人になって終局と、状況によってイベントの連鎖が起きるので、これをdecideで表現する必要があります。
そこで、evolveを使って「もしこのEventが起きたら状態はどうなるか」をカンニングします。

case 'Draw': {
  if (state.status !== 'Playing') return left('ゲーム中ではありません');

  const toPlayer = state.turn;
  const fromPlayer = nextActivePlayer(toPlayer, state.hands);
  const fromHand = findHand(state.hands, fromPlayer);
  const targetCard = fromHand ? fromHand[command.drawIndex] : undefined;
  if (targetCard === undefined) {
    return left('カードの参照が不正です (e.g. 手札の最大数を超えている)');
  }

  // 与えた Event 列を state に畳み込んで「その後の状態」を作るヘルパー
  const applyAll = (events: Event[]) => events.reduce(evolve, state);

  // ここに到達した時点でDrawnは確実に起きる
  const drawnEvent: Event = { tag: 'Drawn', from: fromPlayer, to: toPlayer, card: targetCard };

  // 1. 引いてできたペアを捨てる
  const pairEvents = pairDiscards(applyAll([drawnEvent]).hands);

  // 2. 引いた後 + ペアを捨てた後に手札が空ならwin
  const afterPairs = applyAll([drawnEvent, ...pairEvents]);
  const wonEvents: Event[] = entries(afterPairs.hands)
    .filter(([, hand]) => hand.length === 0)
    .map(([player]) => ({ tag: 'Won', player }));

  // 3. wonの後に残り1人なら、終局(その1人が敗者になる)
  const afterWon = applyAll([drawnEvent, ...pairEvents, ...wonEvents]);
  const remaining = entries(afterWon.hands);
  const gameEndedEvents: Event[] =
    remaining.length === 1 ? [{ tag: 'GameEnded', loser: remaining[0][0] }] : [];

  return right([drawnEvent, ...pairEvents, ...wonEvents, ...gameEndedEvents]);
}

evolveを活用することでイベントの波及をシンプルに書けます。「引いた後の手札」を見てペアを判定し、「ペアを捨てた後の手札」を見てあがりを判定し、「あがりを反映した後」を見て終局を判定する。各ステップでapplyAllに渡すEvent列を継ぎ足していくだけです。

そして最終的に返すのは[drawnEvent, ...pairEvents, ...wonEvents, ...gameEndedEvents]という確定したEventの列になります。decideは本物の状態を一切書き換えていません。 試算に使ったのはあくまでローカルな計算用のStateで、実際の状態の更新は、この戻り値を受け取った呼び出し側がevolveで行います。

最後に、これらをまとめてDeciderにします。

export const oldMaidDecider: Decider<GameState, Command, Event> = {
  decide,
  evolve,
  initial: initialState,
};

Deciderを動かす

ババ抜きのdeciderを作ることができたので、過去のEventの列から今の状態を再現する rehydrate と、今の状態に対して行いたいCommandを入力する handle というユーティリティを作っておきます。

// Event列を畳み込んで現在状態を復元する
export function rehydrate<S, C, E>(decider: Decider<S, C, E>, events: E[]): S {
  return events.reduce((state, event) => decider.evolve(state, event), decider.initial);
}

// 履歴を畳み込んで現在状態を作り、そこへコマンドをdecideする
export function handle<S, C, E>(decider: Decider<S, C, E>, history: E[], command: C) {
  return decider.decide(command, rehydrate(decider, history));
}

具体的なイメージはそれぞれ以下のようになります。
image.png
image.png

この rehydratehandle という2つのユーティリティを使えば、UI層での表現はとてもシンプルなものにできます。
現在の盤面の状況は rehydrate が出力するGameStateをそのまま描画すればよく、 "ゲームを始める"・"左隣の人からカードを引く" といったアクションは対応するCommandを組み立てて handle に渡して返されるEventをスタックしていけばよいだけです。

image.png
↑ claudeにこんな感じでフロント部分を作ってもらいました (再掲)

"監査可能"ババ抜き

ここまでで、ババ抜きはEvent列だけを真実として動くようになりました。(どこにもゲームの状態は保持されていません!)
この作りには、もう一つ嬉しい性質があります。監査可能(auditable) であることです。

decideはルールに反するEventを決して作りません。だからEvent列は、

  • 「いつ・誰が・何を」した完全な記録であると同時に、
  • 「それがルールに従って起きたか」を後から1件ずつ確かめられる証拠

という二役を兼ねます。単純なCRUDでは「不正があったかどうか分からない」が結論でした。でもEvent列があれば、不正を見つけることも、そして不正がなかったと証明することもできます。今回はこの潔白の証明をやってみます。

もう一度、最初の問い合わせに戻りましょう。

左隣の参加者が不正をして私の手札がジョーカー1枚になって負けた

この負けたプレイヤー(問い合わせ元のユーザー)をP0とします。手元には以下のようなEvent列が残っています。

[
  { "tag": "Started", "dealtHands": { "0": [...], "1": [...], "2": [...] } },
  { "tag": "PairDiscarded", "who": 0, "rank": 2 },
  { "tag": "PairDiscarded", "who": 2, "rank": 13 },

  // …(中略:各プレイヤーが順に引いては捨てる)…

  { "tag": "Drawn", "from": 1, "to": 0, "card": { "tag": "Joker" } },

  // …(中略:P1があがる)…

  { "tag": "Drawn", "from": 0, "to": 2, "card": { "tag": "Pip", "rank": 8 } },
  { "tag": "PairDiscarded", "who": 2, "rank": 8 },
  { "tag": "Won", "player": 2 },
  { "tag": "GameEnded", "loser": 0 }
]

ゲーム全体がルール通りに進んだかを監査する

Drawnイベントは、必ず「手番の人(to)が、その左隣(from)から引く」形でしか起きません。これはdecideの実装が保証している条件です。ならばEvent列を頭から再生しながら、各Drawnがこの形を守っているかを照合すればよいだけです。引いたカードが本当にその時点で相手の手札にあったかまで見ておきます。

// その時点のGameStateで、そのDrawnが正規か(手番・引く相手・引いたカードの実在)
const isLegalDraw = (state: GameState, e: Extract<Event, { tag: 'Drawn' }>): boolean =>
  e.to === state.turn &&
  e.from === nextActivePlayer(state.turn, state.hands) &&
  (state.hands[e.from] ?? []).some((c) => cardEq(c, e.card));

// reduceの各ステップの値をすべて残すためのヘルパー
const scan = <A, B>(xs: A[], f: (acc: B, x: A) => B, init: B): B[] =>
  xs.reduce<B[]>((acc, x) => [...acc, f(acc.at(-1)!, x)], [init]);

function findIllegalDraw(events: Event[]): number | undefined {
  // states[i] = events[i] を迎える直前の状態(rehydrate の途中経過)
  const states = scan(events, evolve, oldMaidDecider.initial);
  const i = events.findIndex(
    (e, idx) => e.tag === 'Drawn' && !isLegalDraw(states[idx], e),
  );
  return i === -1 ? undefined : i;
}

これを問題の試合のEvent列に通すと、undefinedが返ってきました。ルールを破ったDrawnは1件もありません。ゲームは最初から最後まで、正規の手順だけで進んでいたことになります。

ジョーカーがP0に渡った瞬間を特定する

不正がなかったことは分かりましたが、念のため「ではP0はどうやってジョーカーを掴んだのか」もEvent列から拾えます。Event列からジョーカーのDrawnだけを抜き出します。

// ジョーカーが誰から誰へ渡ったかをEvent列から拾う
const jokerMoves = events.filter(
  (e): e is Extract<Event, { tag: 'Drawn' }> =>
    e.tag === 'Drawn' && e.card.tag === 'Joker',
);

jokerMovesを使って調べてみると、最後にジョーカーが引かれたのは、こんなDrawnでした。

{ "tag": "Drawn", "from": 1, "to": 0, "card": { "tag": "Joker" } }

toはP0、fromはP1。そして先ほどの監査で、このDrawnも正規なイベントであることは確認済みです。つまりこれは、P0が自分の手番の時に、左隣のP1から引いた結果でした。P1が勝手に押し付けたのではなく、ただP0自身が引き当ててしまった、ということです。

以上の調査によって、システムに非はなく清廉潔白だったことが証明されました👍

おわりに

今回はババ抜きという身近な題材を例に、Functional Event Sourcingの手法であるDeciderについて紹介しました。
イベントソーシングのメリットとして監査可能である点を中心にとりあげましたが、他にもいろいろなメリットがあります。
例えば、

  • 任意時点での再現ができ、調査・不具合の再現が容易である
  • 拡張性: 新しい機能はEvent/Commandの定義を拡張して、deciderにロジックを追加するだけ
  • 現在の状態はイベントの列から復元されるものなので原理的に状態との不整合が起きえない
  • イベント列は起きた出来事の意味そのものなので、設計時に想定していなかった集計や問いにも後から答えられる

などです。

ただし、勘の良い方はお気付きだと思いますが以下に挙げるようなデメリットもあります。

  • 現在の状態を復元するためにEvent列を毎回走査する必要があり、計算コストが高い
  • 全てのEvent列を保持する必要がありストレージを圧迫する
  • (CRUDになれているほど) 学習・設計コストが高く、適切にドメインロジックを実装できる必要がある
  • 「イベントは真実であり追記専用である(=消せない)」と「個人情報の削除要請」は本質的に衝突しており、個人データと相性が悪い
    • crypto-shreddingのように鍵を別に持っておいて、削除要請が来たら鍵を消して復号不可能にする、などの回避策はある

Microsoftもイベントソーシングパターンの冒頭で以下のように述べていて、実際に採用するかどうかは慎重に判断する必要があります。

イベント ソーシングは、重要なトレードオフを導入する複雑なパターンです。 データの格納、コンカレンシーの処理、スキーマの進化、クエリの状態の変更が行われます。 イベント ソーシング ソリューションとの間の移行にはコストがかかります。パターンを採用した後は、それを使用するシステムの部分で将来の設計上の決定が制約されます。 監査可能性や履歴の再構築などの利点がパターンの複雑さを正当化する場合は、イベント ソーシングを採用します。 ほとんどのシステムとシステムのほとんどの部分では、従来のデータ管理で十分です8

とはいえ、「誰が・いつ・何を」を後から問われることが明らかであれば、イベントソーシングとDeciderは有力な選択肢になります。監査可能性や履歴の再現が効く題材なら、一度検討してみる価値はあると思います。(ババ抜きに採用するのは過剰です)

本記事が何かの参考になれば幸いです!
最後までお読みいただき、ありがとうございました

  1. 厳密にはRankはPipではありませんが、ババ抜きではスートを見ないため、本記事では数字=ランク=Pipで統一します。

  2. https://www.smbcnikko.co.jp/terms/eng/b/E0140.html

  3. 「監査ログを別途残せば調べられるのでは?」と思った方へ。その通りで、ログでもある程度は追えます。ただ普通のログはDBとは別に置かれた副次的な記録で、書き忘れ・DBとの不整合・構造化されていない、といったデメリットがつきまといます。一方イベントソーシングは発想が逆で、イベント列こそが唯一の正であり、現在状態はそこから再生して導く物にすぎません。だから記録と現実が原理的にズレません。つまり「ログを足すか否か」ではなく、真実をどこに置くかが両者の本質的な違いになります。

  4. Jérémie Chassaing, "Functional Event Sourcing Decider", thinkbeforecoding.com, 2021-12-17. https://thinkbeforecoding.com/post/2021/12/17/functional-event-sourcing-decider

  5. Commandはシステム内のInternal Commandとシステム外External Commandに大別できますが、ドメインが実際に受け取るのは前者で、本記事では単にCommandと表記します。

  6. evolveを使わずに、状態を随時書き換えるような実装をすることも可能ですが、immutableに実装することで大きなメリットを得ることができます。

  7. isTerminalは、その状態がこれ以上変化しない「終了状態」かどうかを返す判定器です。ゲームが終わった後や注文の出荷が完了した後など、二度とdecideが呼ばれない状態を表します。状態をいつ破棄・アーカイブしてよいかを設計するうえで重要になります。

  8. https://learn.microsoft.com/ja-jp/azure/architecture/patterns/event-sourcing

14
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?