🦆

シンプルなイベントソーシングをC#、Rust、Goに続き、Typescriptで作ってみた

2024/12/19に公開

株式会社ジェイテックジャパン CTOの高丘 @tomohisaです。

Sekibanという、C#のイベントソーシングフレームワークを作っています。

https://github.com/J-Tech-Japan/Sekiban

その新しいコンセプト(関数型で効率的な書き方)のために、まず、インメモリで動作する、イベントソーシングのコンセプトをC#で作りました。そちらの記事はこちら。

https://zenn.dev/jtechjapan_pub/articles/f7968a3f2fb6d5

C#で2日くらいでこれができ、その後、Rustでも苦戦しながら、2日で似たものを作りました。

https://zenn.dev/jtechjapan_pub/articles/2ca0d357dffc4b

Rustでできるなら、Goならもう少し楽にできるのではないかと思ったところ、確かに、コピーでできるコードが多かったので、1日でできました。

https://zenn.dev/jtechjapan_pub/articles/1cd1ca43701960

そしてもう一つ試してみたかった言語、Typescriptでも書いてみたというのが今回の記事になります。Typescriptは個人的にはVue.jsのフロントエンドでは使っていたのですが、個人的にフロントエンドでガッツリ作る経験が少なかったため、型をしっかり使ってバックエンドのコンポーネントとして書くのは初めてでした。

Typescript版のSuper Simple Event Sourcing はこちら

https://github.com/J-Tech-Japan/SuperSimpleEventSourcing/tree/main/typescript

実行コード

console.tsx
let repository = new Repository();
let commandExecutor = new CommandExecutor(repository);
// use costom function to execute command.
let createBranchCommand = new CreateBranch('London', 'UK');
let commandResponse = commandExecutor.ExecuteCommand(
    createBranchCommand, 
    new BranchProjector(), 
    (command) => new PartitionKeys(uuidv4(), 'Branch', 'Branch'),
    (command, context) => { return new BranchCreated(command.name, command.country);});

// command with code has implements handler functions
let changeBranchNameCommand = new ChangeBranchName('Manchester', commandResponse.partitionKeys);
commandResponse = commandExecutor.ExecuteCommandWithHandler(changeBranchNameCommand);
commandResponse = commandExecutor.ExecuteCommandWithHandler(new ChangeBranchCountry('England', commandResponse.partitionKeys));

let aggregate3 = repository.Load(commandResponse.partitionKeys, new BranchProjector());
console.log(aggregate3);

出力結果:

Aggregate {
  Payload: Branch { type: 'Branch', name: 'Manchester', country: 'England' },
  PartitionKeys: PartitionKeys {
    AggregateID: 'fdbd2894-e042-4d2c-ae4a-ca7f7940fd76',
    Group: 'Branch',
    RootPartitionKey: 'Branch'
  },
  Version: 3,
  LastSortableUniqueID: '063870164460486003201410602783'
}

基本クラスは以下のものです。

  • Repository : イベントをインメモリに保存したり、保存されたイベントから集約を呼び出す機能(複数のプロジェクタに対して汎用)
  • CommandExecutor: コマンドを実行したら、Repositoryにイベントを保存する、すでに保存された集約に対しては、現在の集約状態を呼び出して、追加のコマンドを定義する。Goと違い、Typescriptには、クラスのメソッドに対してジェネリックを使用できるので、スムーズに書くことができました。

Branchの集約のパーツも、プロジェクト内に定義しています。以下で、集約パーツのコードを紹介します。

ドメインのコード① - イベント

events.ts
export class BranchCreated implements EventPayload {
    type: string = 'BranchCreated';
    constructor(public readonly name: string, public readonly country: string) {}
    IsEventPayload(): boolean {
        return true;
    }
}
export class BranchNameChanged implements EventPayload {
    type: string = 'BranchNameChanged';
    constructor(public readonly name: string) {}
    IsEventPayload(): boolean {
        return true;
    }
}
export class BranchCountryChanged implements EventPayload {
    type: string = 'BranchCountryChanged';
    constructor(public country: string) {}
    IsEventPayload(): boolean {
        return true;
    }
}

パターンマッチングを効率的に行うために、type:プロパティを作っていたり、IsEventPayloadを定義しています。constructor内に、プロパティの内容を書くことによって、変更しないことを示したり、また内部のプライベートプロパティを書かなくていいようになっている点は、データを簡単に定義するという目的では、とても良いと感じました。

ドメインのコード② - 集約

aggregate.ts
export class Branch implements AggregatePayload {
    type: string = 'Branch';
    constructor(public readonly name: string, public readonly country: string) {}
    IsAggregatePayload() : boolean {
        return true;
    }
}

イベントと同じく、データと簡単なメソッドです。このデータがイベントで構成されていきます。イベントの集合に対して、複数の集約の種別を定義することも可能ですが、集約を作るためには、次に書く、集約プロジェクターを定義する必要があります。

ドメインのコード③ - 集約プロジェクター

BranchProjector.ts
export class BranchProjector implements AggregateProjector {
    constructor() {}
    Project(payload: AggregatePayload, ev: EventCommon): AggregatePayload {
        if (payload instanceof Branch) {
            if (ev.Payload instanceof BranchNameChanged) {
                return new Branch(ev.Payload.name, payload.country);
            } else if (ev.Payload instanceof BranchCountryChanged) {
                return new Branch(payload.name, ev.Payload.country);
            }
        } else if (payload instanceof EmptyAggregatePayload && ev.Payload instanceof BranchCreated) {
            return new Branch(ev.Payload.name, ev.Payload.country);
        }
        return payload;
    }
    GetVersion(): string { return '1.0.0'; }

}

Sekibanにおいて、すべての集約は、EmptyAggregatePayloadで始まります。新規集約を開始する処理に関しては、集約がEmptyAggregatePayloadで特定のイベントが来たときに、新たな集約の型を返します。この場合、BranchCreatedイベントが来た時に、Branch集約に返します。

その他のイベントの時は、Branch集約をキープしたまま、Branch内のデータを変えていきます。この場合は、Branch型をキープしているのですが、型を複数定義して、状態によって型を変えることもできます。

  • ActiveBranch : ユーザーを追加できる
  • InactiveBranch : ユーザーを追加できないが、閲覧はできる
  • DeletedBranch : 削除されたBranch、閲覧もできない
    この型を変えることによって、以下で説明するCommandが、イベントによって特定の型の時だけコマンドを実行できるように構成することができます。(今回のSimple Typescript ではここまでは書いていません。)
  • ChangeBranchName は、ActiveBranchにしか実行できない
  • MakeBranchInactive はActiveBranchにしか実行できない
  • RestartInactiveBranch はInactiveBranchにしか実行できない
    など。

Typescript でちょっと驚いたのは、switch 式を用いたパターンマッチングがないので、switch case やif文を用いて構成しないといけないのはちょっと残念でした。(多分パッケージを使って、パターンマッチを書くようなものはあるのかも知れません)

https://x.com/tomohisa/status/1868801270796161133

もうひとつ、Branchの一部を変える書き方で、コンストラクタやファクトリメソッドを増やす以外の効率的なものが見つかりませんでした。

https://x.com/tomohisa/status/1868804605167255623

C#のように return branch with { Name = ev.Payload.Name } とかけると楽だなと感じました。

ドメインのコード④ - コマンドおよびそのハンドラー

commands.ts
export class CreateBranch implements Command {
    IsCommand(): boolean {
        return true;
    }
    constructor(public readonly name: string, public readonly country: string) {}
}
export class ChangeBranchName implements CommandWithHandler<ChangeBranchName> {
    constructor(public readonly name: string, public readonly partitionKeys: PartitionKeys ) {}
    GetAggregateProjector(): AggregateProjector {return new BranchProjector();}
    PartitionKeysProvider(command: ChangeBranchName): PartitionKeys { return command.partitionKeys;}
    CommandHandler(command: ChangeBranchName, context: CommandContext): EventPayloadOrNone {
        return new BranchNameChanged(command.name);
    }
    IsCommand(): boolean { return true;}
}
export class ChangeBranchCountry implements CommandWithHandler<ChangeBranchCountry> {
    constructor(public readonly country: string, public readonly partitionKeys: PartitionKeys ) {}
    GetAggregateProjector(): AggregateProjector {return new BranchProjector();}
    PartitionKeysProvider(command: ChangeBranchCountry): PartitionKeys { return command.partitionKeys;}
    CommandHandler(command: ChangeBranchCountry, context: CommandContext): EventPayloadOrNone {
        return new BranchCountryChanged(command.country);
    }
    IsCommand(): boolean { return true;}
}

コマンドの目的および機能は以下のとおりです

  • 入力内容の決定 : Command
  • 指定するパーティションの決定(イベントストリームの指定)
  • コマンドを受け取り、どんなイベントを返すのか、それとも何も返さないのかを決定する: コマンドハンドラー

Commandにはいくつかの定義方法がありますが、主に2つあります。

  1. コマンドの型だけ定義して、その機能はExecuteCommandに直接渡す
    CreateBranchCommand, ChangeBranchNameCommandはこのスタイルで書いています。
  2. コマンドとハンドラーを一緒に定義して利用できるように準備しておく
    ChangeBranchCountryNameCommandはこのスタイルで書いています。
    個人的には、Handlerの責務はドメインが持っているのが良いと思いますので、こちらの書き方を個人的には主に用いています。

コマンドの責務はどのイベントを保存するかを決めるところまでで、その保存されたイベントが集約にどのような影響を与えるかは、Projectorが決定します。

上記の4つのドメインの構成要素を定義したのちに、最初に書いた実行コードを記述することができます。

Typescriptで書いてみて

Typescript で書いてみて、特に綺麗にかけて嬉しかったところは、コマンドハンドラーが、EventPayload を返すか、何も返さないというケースです。それをTypescriptでは以下のようにライブラリで定義しています。

lib.ts
export type None = {};
export const None : None = {};
export type EventPayloadOrNone = EventPayload | None;

export interface CommandWithHandler<TCommand extends Command> extends Command {
  GetAggregateProjector(): AggregateProjector;
  PartitionKeysProvider(command: TCommand): PartitionKeys;
  CommandHandler(command: TCommand, context: CommandContext): EventPayloadOrNone;
}
domain.ts
export class ChangeBranchCountry implements CommandWithHandler<ChangeBranchCountry> {
    constructor(public readonly country: string, public readonly partitionKeys: PartitionKeys ) {}
    GetAggregateProjector(): AggregateProjector {return new BranchProjector();}
    PartitionKeysProvider(command: ChangeBranchCountry): PartitionKeys { return command.partitionKeys;}
    CommandHandler(command: ChangeBranchCountry, context: CommandContext): EventPayloadOrNone {
        return new BranchCountryChanged(command.country);
    }
    IsCommand(): boolean { return true;}
}

型の直和型を定義できることにより、エラーか、エラーなし、データありか、データなしのような型を簡単に合成できます。ORを使っているので、特定のインスタンスを生成しないで、EventPayloadを返す場合に、そのままreturnすることができます。

これはC#でも現在Tagged Unionとして仕様を考慮中ですが、名義的型付け(Nominal Typing)を採用しているC#ではなかなかTypescriptのようにはいかないですね。Typescriptでは構造的型付け(Structural Typing)という、
型の名前ではなく、「その型がどのような構造(メソッド、フィールド、プロパティ)を持っているか」に基づいて、型の互換性を判断する仕組みを使っているので、直和型を綺麗に実装が可能です。

このように、C#と比べて良い部分、苦手な部分はありましたが、Typescriptではかなりバランスよく書けたと思います。

SuperSimpleEventSourcingに関しては、現在、C#、Rust 、Go、Typescriptと4つの言語でほぼ同じ、イベントソーシングのコンセプトをシンプルな実装で作りました。どれも人気の言語で、良い言語と感じましたが、個人的にはC#が慣れているというだけでなく、よく設計すれば冗長さを省いて綺麗にかけ、しかもGoと比べるとスピードにも遜色なく、Typescriptよりは速いので、良いと感じました。

余裕があれば別の言語も書いてみたい。言語や機能のリクエストがあればこちらのコメントやXでのコメントでお願いします!!コメントがあるとやる気が出ます。

シンプルなEvent Sourcingコンセプト実装という形ですが、永続化などもつけていけば、普通に使えるイベントソーシングフレームワークにもなるのではないかと密かに考えています。

https://github.com/J-Tech-Japan/Sekiban

あと、SekibanのGitHubにスターをしてくださるととても喜びます!!

ジェイテックジャパンブログ

Discussion