ドメインイベントのデータ上の扱いについて紹介 (CQRS+ES conf 2026の補足)

この記事は、10X 新春ブログリレー 2026の記事です。


鈴木です。2026年1月に開催されたCQRS+ES Conference 2026にて「ネットスーパー事業におけるCQRS+ES的アプローチの取り組み紹介」というタイトルで登壇しました。 cqrs-es-con.jp

CQRS+ES conf 2026の当日は

  • Stailerにある注文を中心とした業務影響がある課題の共有
  • 共有した課題がCQRSとイベントソーシングにある要素でどう解決できるのか

この2点にフォーカスして話をさせていただきました。

当日使った資料はこちらです。

speakerdeck.com

この記事では当日話きれなかった具体的な方法を紹介しようと思います。

イベントのスキーマ管理と運用

話の中ではイベントの重要性を話していました。

このイベントはいわゆるドメインイベントで業務中の語彙に近いことを目指して定義しています。

これをデータベースに残すという点での工夫についてです。Stailerでは中心のデータベースにFirestoreを採用しています。

イベントのデータ構造の定義ができるようにする

FirestoreはRDBのスキーマのように永続化するデータのフォーマットを事前に決めることが、Firestoreの機能では実現できません。

イベントはアプリケーションとアプリケーションを利用した業務のその後の改善にとってもっとも重要なデータで、Firestoreのトリガーで最終的に他のシステムやデータ基盤に流れていくことを考えると構造については自由に扱えることより決まった構造で扱えた方が暗黙知の共有が必要なく嬉しいと考えています。

pub.dev

Stailerのイベントソーシングの考え方を取り入れている箇所以外で永続化するFirestoreドキュメントを扱う新系統以外では、次のようにDartのクラス構造を正のフォーマットにしていました。

import 'package:json_annotation/json_annotation.dart';

part 'models.g.dart';

@JsonSerializable()
class Product {
  final String id;
  final String name;
  final String sku;
  final int price;

  Product({
    required this.id,
    required this.name,
    required this.sku,
    required this.price,
  });

    /// _$ProductFromJsonはコードジェネレータが生成する関数
  factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json);
    /// _$ProductToJsonはコードジェネレータが生成する関数
  Map<String, dynamic> toJson() => _$ProductToJson(this);
}

「事前定義がまったくない」ということはないですが、Dartのクラス構造に基づくので個人的には

  • Dartが読めて、json_serializableの知識がないと出力されるデータ構造(JSON)が把握できない
  • json_serializableからjson_schemaを出力するのも検討できるがツールがそのようなツールを自作するのは避けたい

という点がありDartへの強い依存をさけたかったです。

そこでとったアプローチがprotobufで構造を定義しJSONとして値を扱うということです。

既存のコードはjson_serializableを使って生成したJSON互換のあるMapを社内のFirestoreライブラリはFirestoreのドキュメントとしてエンコードして永続化してます。

つまりデータをJSONとしてFirestoreライブラリに渡せばいいわけなので、Firestoreライブラリ自体に変更を加えることなく「構造をprotobufで定義して、JSONでやりとりする」が可能でした。

「protobufで定義した構造を値ではJSONとして扱う」に関してはLayerXさんのこちらのブログが参考になります。

tech.layerx.co.jp

イベントのデータ構造変更にどう対応しているか

en.wikipedia.org

Schema evolutionというやつです。

イベントの定義とイベントのproducer側になるアプリケーションでは次の方針で対応しています。

  • ちょっとした変更ならprotobufのdeperecatedオプションを活用する
  • 構造が根本的に変わるならprotobuf&アプリケーション上で別のEventとして扱う
  • 新旧でフィールドがあるならしばらくダブルライトし、consumer側が対応しきれば旧フィールドへの書き込みをとめる

「構造が根本的に変わるならprotobuf&アプリケーション上で別のEventとして扱う」に関しては具体的にはPickedイベントがあったが構造が大きく変わるならPickedV2イベントを定義するという感じです。

たとえばプロトコルバッファでの定義は次のようにするイメージです。

// V1: 既存の Picked イベント
message Picked {
    string event_id = 1;           // イベント識別子
    string order_id = 2;           // 注文ID
    string sku = 3;                // 商品SKU
    int32 quantity = 4;            // 個数
    int64 occurred_at = 5;         // 発生時刻 (UNIX epoch millis)
}

// V2: 構造を大きく変更した PickedV2 イベント
message PickedV2 {
    string event_id = 1;           // イベント識別子(V1 と共通にすることが可能)
    string order_id = 2;           // 注文ID
    repeated Item items = 3;       // 複数アイテム対応に変更
    int64 occurred_at = 4;         // 発生時刻 (UNIX epoch millis)

    message Item {
        string sku = 1;
        int32 quantity = 2;
    }
}

フィールドのダブルライトとは違いイベントを新しく増やして対応ならアプリケーション的には今までPickedイベントが発生していたところからはPickedV2イベントしか発生しないようにしています。ダブル発生はしないってことです。

イベントを持って状態遷移が起きるということを考えるとダブル発生は2度状態遷移がおきてしまいます。

新しいイベントを増やして構造変更に対応する時にはproducer側で構造を定義し、consumerが対応したあとにproducer側で実際にイベントを発生させることになります。

たまり続けるイベントをどうしているか

イベントは溜まり続けることでストレージ利用料が増えていきます。

また、データ構造変更があったときにマイグレーションをどうするか、という観点が出てきます。

Stailerのピックパックの領域では一定期間でイベントであってもFirestoreからデータ削除をしています。

これはピックパックの作業が指示されてからせいぜい2週間で作業が完了し、アプリケーションでそのデータに関わる作業が発生しない特徴に合わせた対応です。

データ分析では過去の作業を参照しますが、データ基盤側でイベントを取り込んでいるためFirestore上のイベントは不要になります。

削除できることから、データ構造変更時のマイグレーションは緩やかに考えており、ほとんど行っていません。

またイベントは不変です。そのためその時起きたことをそのまま残す観点から、バグで間違ったデータが書き込まれていてもイベント自体は修正しません。

アプリケーション状態の帳尻合わせが必要な場合は、集約ルートの状態が正しくなるように集約ルートを操作してイベントを発生させます。

具体的には、ピック数が常に+1されてしまったような事象を訂正するには集約ルートにピック数をNで上書きするコマンド(メソッド)を定義し、訂正したイベントが発生するようにしています。

おわりに

カンファレンスでの登壇といった自分的には大きなイベントを終えましたが、まだ取り組みを紹介していくつもりです。

product.10x.co.jp

product.10x.co.jp

product.10x.co.jp

product.10x.co.jp

ネットスーパー事業では絶賛エンジニアを募集中です。カジュアル面談もwelcomeです。 ご応募お待ちしております。

open.talentio.com