🤔

【Flutter】Event Sourcingアーキテクチャを検討したが、99%のアプリに不要だった話

に公開

はじめに

アプリ内でユーザー操作を記録する機能を作っていたところ、「Event Sourcingを使えば履歴が残って安心」という記事をみつけてEvent Sourcingというアーキテクチャパターンに興味を持ち、モバイルアプリに必要かどうかを調査・実装してみました。

実際に使ってみると、とても大変でした。コードは複雑になるし、バグの原因を探すのも一苦労。「これって本当に必要だったのかな?」と思うことが何度もありました。

今回はそんな経験をもとに、Event Sourcingがどんなものなのか、そしていつ使うべきで、いつ使わない方がいいのかを紹介できればと思います。

身近な例でEvent Sourcingを理解する

Event Sourcingはモバイルアプリでは馴染みが薄い用語ですが、そのシステムは身近に沢山あります。例えば銀行の通帳で考えてみましょう。

普通のアプリの考え方

あなたの残高: 50,000円

これだけ。シンプルですね。

でも通帳を見ると違います。

銀行の通帳(これがEvent Sourcing)

1月1日  口座開設           0円    残高: 0円
1月5日  給与振込      +300,000円  残高: 300,000円
1月10日 家賃引き落とし  -80,000円  残高: 220,000円
1月15日 コンビニ支払い  -500円    残高: 219,500円
...
12月31日 ボーナス     +100,000円  残高: 50,000円

銀行は「残高」そのものは保存していません。全ての取引履歴だけを保存して、残高は毎回計算しています。これがEvent Sourcingの基本的な考え方です。

「なぜ50,000円なのか?」と聞かれたら、取引履歴を見せて完璧に説明できますよね。

実際にEvent Sourcingのコードを書いてみる

せっかくなので、Event Sourcingがどんなものか、簡単なコードで体験してみましょう。

1. 起こった出来事を記録する

// 出来事の基本形
abstract class DomainEvent {
  final String id;
  final DateTime timestamp;
  const DomainEvent(this.id, this.timestamp);
}

// 「ユーザーが登録された」という出来事
class UserRegisteredEvent extends DomainEvent {
  final String userId;
  final String name;
  final int age;

  UserRegisteredEvent(this.userId, this.name, this.age)
      : super('${DateTime.now().millisecondsSinceEpoch}', DateTime.now());
}

// 「誕生日を迎えた」という出来事
class BirthdayEvent extends DomainEvent {
  final String userId;

  BirthdayEvent(this.userId)
      : super('${DateTime.now().millisecondsSinceEpoch}', DateTime.now());
}

ここで大切なのは、これらが過去に起こった事実だということです。「田中さんが1月1日に登録された」「12月1日に誕生日だった」など、変更できない出来事として記録します。

2. 出来事を保存する場所

class EventStore extends ChangeNotifier {
  final List<DomainEvent> _events = [];
  
  void append(DomainEvent event) {
    _events.add(event);
    notifyListeners();
  }

  List<DomainEvent> getEventsFor(String userId) {
    return _events.where((event) {
      return (event is UserRegisteredEvent && event.userId == userId) ||
             (event is BirthdayEvent && event.userId == userId);
    }).toList();
  }
}

Event Storeは「出来事の保管庫」みたいなものです。現在の状態は保存されていません。ただ出来事が順番に積み重なっているだけです。

3. 過去を振り返って現在を作る

class UserAggregate {
  final String id;
  String _name = '';
  int _age = 0;
  
  UserAggregate(this.id);
  
  String get name => _name;
  int get age => _age;
  
  // 過去の出来事から現在の状態を作る
  void loadFromHistory(List<DomainEvent> events) {
    for (final event in events) {
      if (event is UserRegisteredEvent) {
        _name = event.name;
        _age = event.age;
      } else if (event is BirthdayEvent) {
        _age++;
      }
    }
  }
  
  void celebrateBirthday() {
    final event = BirthdayEvent(id);
    _age++; // イベント適用
    // 実際の実装では、ここでEvent Storeに保存
  }
}

これがEvent Sourcingの魔法です。過去の出来事を順番に再生することで、現在の状態を作り出します。映画を最初から見直すような感じですね。

でも、普通のアプリには重すぎませんか?

正直に言います。上のコードを見て「めんどくさそう...」と思いませんでしたか?その感覚は正しいです

普通のTodoアプリなら、こんなシンプルなコードで十分です:

class TodoNotifier extends ChangeNotifier {
  final List<Todo> _todos = [];
  
  List<Todo> get todos => _todos;

  void addTodo(String title) {
    _todos.add(Todo(title));
    notifyListeners();
  }

  void toggleTodo(int index) {
    _todos[index].completed = !_todos[index].completed;
    notifyListeners();
  }
}

class Todo {
  final String title;
  bool completed = false;
  
  Todo(this.title);
}

どちらが理解しやすく、素早く書けるでしょうか?多くの場合、シンプルな方が正解です。

じゃあ、いつEvent Sourcingを使うの?

でも、世の中には「履歴が超重要」な分野があります。

例:お金を扱う世界

class WalletAggregate {
  void deposit(Money amount, String source) {
    // 法的に記録が必要
    final event = MoneyDepositedEvent(
      walletId: id,
      amount: amount,
      source: source,
      timestamp: DateTime.now(),
      deviceId: DeviceInfo().deviceId, // 不正防止のため
    );
    applyEvent(event);
  }
}

銀行で「残高が間違ってます!」と言われたら、銀行は取引履歴を見せて一つひとつ説明してくれますよね。

PayPayやメルペイなどの決済アプリでは、「なぜこの残高になったのか」を完璧に説明できないと、法的な問題になってしまいます。

例:命に関わる世界

病院では、「この薬を処方した理由は何か」を、医療安全のため誰がいつ何を処方したかを記録しておく必要があります。

class PatientRecordAggregate {
  void prescribeMedicine(Medicine medicine, String doctorId) {
    // 医療安全のため、誰がいつ何を処方したかを記録
    final event = MedicinePrescribedEvent(
      patientId: id,
      medicine: medicine,
      prescribedBy: doctorId,
      timestamp: DateTime.now(),
      reason: _currentDiagnosis,
    );
    applyEvent(event);
  }
}

モバイルアプリではどうでしょうか?

ではモバイルアプリの場合はどうでしょうか?実際のところ、モバイルアプリでEvent Sourcingが本当に必要になることはかなり稀で、全体の1%未満です。(筆者の観測範囲です)

こんな時には価値があります

お絵描きアプリを考えてみてください:

class DrawingHistory extends ChangeNotifier {
  final List<List<Offset>> _strokes = [];
  
  List<List<Offset>> get strokes => _strokes;
  
  void addStroke(List<Offset> points) {
    _strokes.add(points);
    notifyListeners();
  }
  
  void undo() {
    if (_strokes.isNotEmpty) {
      _strokes.removeLast();
      notifyListeners();
    }
  }
}

ここでは「描画の過程そのもの」に価値があります。Procreateの「タイムラプス動画」機能を思い出してください。あれはまさにEvent Sourcingの力です。

あなたのアプリには必要ですか?

導入を検討する際は、以下を参考にしてみてください。

1. 法的な説明責任がありますか?

  • 「なぜその状態になったか」を裁判所で説明する必要がある

2. 過去の状態に戻ることが核心機能ですか?

  • 「30分前の状態に戻したい」が頻繁にある

3. 同じデータから全く違う分析が必要ですか?

  • 売上データ、在庫データ、トレンドデータを同時に出したい

どれかYESなら Event Sourcingを検討できますが、 すべてNOなら、普通のCRUDで十分です。

おわりに:強い道具ほど、使う場所を選ぼう

私は「ユーザーの行動を完璧に記録したい」という理由だけでEvent Sourcingを導入してみましたが、その結果

  • 開発が遅くなった: 簡単な機能を追加するのに倍の時間
  • バグが複雑になった: 「なんで画面が更新されないの?」の原因究明が大変
  • あとで自分でも混乱した: 理解するのに時間がかかる

となり、振り返ると普通のログ機能で十分満たせる要求で、Event Sourcingは完全に過剰でした。

Event Sourcingは強力な技術ですが、強力すぎる道具は使う場所を選びます

  • お金や命に関わる → Event Sourcing を検討してみる
  • 一般的なアプリ → 普通のCRUD + 必要に応じてログ機能
  • クリエイティブなアプリ → 軽量版Event Sourcingもアリ

大抵の技術に言えますが、大切なのは「できるから使う」ではなく「本当に必要だから使う」ということでした。学んだ技術を使わない勇気も、同じくらい大切ですね。

シンプルな解決策で要求を満たせるなら、それが最良の選択です。

あなたが作るアプリが、ユーザーにとって本当に価値のあるものになりますように。そのために最適な技術選択ができますように。

そんな願いを込めて、この記事を書かせていただきました。

Discussion