Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Stailerにおけるコトを残すデータ設計とイベント駆動アーキテクチャ

Avatar for ryota0624 ryota0624
January 10, 2026
160

 Stailerにおけるコトを残すデータ設計とイベント駆動アーキテクチャ

cqrs conf 2026

Avatar for ryota0624

ryota0624

January 10, 2026
Tweet

Transcript

  1. 1. データ不整合の頻発 2. 行動分析の困難さ 3. 信頼性の欠如 4. 調査の困難さ 5. 機能開発の制約

    ざくっと課題の根を整理すると データ設計: 事実が永続化されていない コミュニケーション設計: リアルタイム性が低く、整合性が保てない Before: 抱えていた課題 ALL RIGHTS RESERVED BY 10X, INC. 8
  2. Order とは 「注文にまつわるすべての契約、業務を注文ID で一意にできることすべて詰 め込んだモノ」 注文確定からお渡し完了まで、あらゆる詳細を扱う Stailer のあらゆるアクター EC 、ピック、配送、請求...

    のスタッフが関心を持つ 抱えていた問題 巨大で複雑: あらゆる業務ロジックが混在し、相互に依存 変更の影響範囲: ピックパックの変更が、請求や配送に影響するリスク ステータスの爆発: 「未ピック」 「保留」 「品切れ」... 各領域で考慮が必要 巨大なOrder (注文)クラス ALL RIGHTS RESERVED BY 10X, INC. 9
  3. 1 ドキュメントの限界 Order はFirestore 上で1 つのドキュメントとして表現されていた ピッキング業務は「商品」ごとに並列で行われるが、更新先は単一のOrder 書き込み競合 が頻発 1

    ドキュメントあたりの書き込み頻度制限(1 回/ 秒程度)に抵触 トランザクションスクリプトの弊害 Order のあらゆるフィールドが公開され、スクリプトが自由に書き換え 単体テストが困難 Firestore エミュレータ必須 セットアップが重い Order とFirestore 、業務事情の相性問題 ALL RIGHTS RESERVED BY 10X, INC. 10
  4. 物理削除 or 論理削除? の議論なしに、しれっと物理削除が横行 失われる「事実」 しれっと物理削除が起きるのはなぜ? Order クラスが持っているList をfilter(where) で書き換えるような操作をなんとなく書く

    Order クラスをほぼそのままJSON に変換してFirestore に保存 filter で除外した要素がFirestore 上から物理削除される 横行するしれっと物理削除 ALL RIGHTS RESERVED BY 10X, INC. 14
  5. Firestore 設計の最適化 以前: 注文(Order )単位の巨大なドキュメント 改善後: 「注文に含まれる1 商品」単位のドキュメント 同一注文でも、別商品の操作であれば完全に並列処理が可能に 即時フィードバック

    スタッフの操作は速やかにサーバへ送信 「ピックしすぎ」などのエラーを即座にフィードバック可能に 改善の成果: 並列性と整合性 ALL RIGHTS RESERVED BY 10X, INC. 22
  6. 責務の分離 Picking: 注文の「1 商品」を扱う ピック操作や品切れ操作は商品単位で発生するため Packing: 「注文単位」で商品を扱う 全ての商品が揃ってから箱詰め・確定を行うため 非同期連携 Picking

    での操作(ピック)をトリガーに、Packing へ「パック予定」として計上 結果整合性を受け入れることで、Picking 操作時のレスポンスを高速に維持 注文単位で整合性を保つ必要があるPacking の操作でレスポンスをブロックしない 業務工程が分かれているため、即時の整合性は必須ではない Picking とPacking のモデル設計 ALL RIGHTS RESERVED BY 10X, INC. 23
  7. ピックパック業務領域から見た役割の再定義 Order: ピックパック業務領域外への参照用のデータ(Read Model に近い) Picking/Packing モデル: ピックパック業務の更新系操作を主に受け持つ、新しい系統の読み込み用にも使う 結果整合性への移行 Picking/Packing

    モデルで業務の整合性を担保 発生した Event をトリガーに、Order へ状態を反映 Order への反映は遅延しても良い(結果整合性) Order との関係性 ALL RIGHTS RESERVED BY 10X, INC. 24
  8. Phase 1: 同期実行 ユースケースからイベントハンドラを明示的に呼び出し 同一DB トランザクションで実行(まだ密結合) Phase 2: プログラム的非同期 イベントハンドラを非同期処理で実行

    別のDB トランザクションに分離(レスポンス速度向上) Phase 3: システム的非同期 Eventarc を利用し非同期処理、内部的にはPub/Sub が使われる システム的に分離され、可能な限りのリトライが可能に Order への書き込み競合で失敗しても、ひたすらリトライして最終的に整合させる Order への反映: 3 つのフェーズ ALL RIGHTS RESERVED BY 10X, INC. 25
  9. Future<void> executeUseCase(Transaction tx, PickingId pickingId) async { // 1. リポジトリから対象の集約を取得

    final picking = await repository.get(tx, pickingId); // 2. ドメインロジックの実行(状態遷移) final updated = picking.pick(); // 3. イベント、状態の保存 await repository.store(tx, updated); // 4. 発生したドメインイベントを順次処理 for (final evt in updated.occurredEvents) { await eventHandler.execute(evt, transaction: tx); } } Order への反映: (Phase 1: 同期実行) ALL RIGHTS RESERVED BY 10X, INC. 26
  10. Future<void> executeUseCase(Transaction tx, PickingId pickingId) async { // 1. 集約の取得

    final picking = await repository.get(tx, pickingId); // 2. 状態遷移(ドメインイベントの発生) final updated = picking.pick(); // 3. イベント、状態の保存 await repository.store(tx, updated); // 4. プログラミング的な非同期処理 // 別トランザクション内でイベントを処理 for (final evt in updated.occurredEvents) { unawaited(() async { await eventHandler.execute(evt); }()); } } Order への反映: (Phase 2: プログラム的非同期) ALL RIGHTS RESERVED BY 10X, INC. 27
  11. Future<void> executeUseCase(Transaction tx, PickingId pickingId) async { final picking =

    await repository.get(tx, pickingId); final updated = picking.pick(); // 1. イベント、状態の保存 // 2. イベントはトリガーの仕組みで別のサーバへ送られる await repository.store(tx, updated); } /// 3. 別のサーバにてイベントを処理 Future<void> receiveEvent(evt) { await eventHandler.execute(evt); } Order への反映: (Phase 3: システム的非同期) ALL RIGHTS RESERVED BY 10X, INC. 28