このガイドでは、templates/Sekiban.Pure.Templates/content/Sekiban.Orleans.Aspire/OrleansSekiban.Domain
のテンプレート構造に基づいて、Sekibanを使用したイベントソーシングプロジェクトの作成と操作方法について説明します。
Orleans と Aspire 統合を備えた新しい Sekiban プロジェクトをすぐに作成するには:
# Sekibanテンプレートをインストール
dotnet new install Sekiban.Pure.Templates
# 新しいプロジェクトを作成
dotnet new sekiban-orleans-aspire -n MyProject
このテンプレートには以下が含まれています:
- Orleansのための.NET Aspireホスト
- クラスターストレージ
- グレイン永続ストレージ
- キューストレージ
イベントソーシングは以下の特徴を持つ設計パターンです:
- アプリケーションの状態変更はすべてイベントのシーケンスとして保存される
- これらのイベントが真実の源泉となる
- 現在の状態はイベントを再生することで導き出される
- イベントは不変であり、システムで発生した事実を表す
Sekibanは.NETイベントソーシングフレームワークであり:
- C#アプリケーションでのイベントソーシングの実装を簡素化
- 分散システム用のOrleansとの統合を提供
- 様々なストレージバックエンドをサポート
- ドメインモデルを定義するためのクリーンで型安全なAPIを提供
アグリゲートは2つの主要な部分で構成されています:
-
アグリゲートペイロード: すべてのアグリゲートに存在する基本情報:
- 現在のバージョン
- 最後のイベントID
- その他のシステムレベルのメタデータ
-
ペイロード: 開発者が定義するドメイン固有のデータ
Sekibanでは、アグリゲートはIAggregatePayload
を実装します:
[GenerateSerializer]
public record WeatherForecast(
string Location,
DateOnly Date,
int TemperatureC,
string Summary
) : IAggregatePayload
{
public int GetTemperatureF()
{
return 32 + (int)(TemperatureC / 0.5556);
}
}
ポイント:
- 不変性のためにC#レコードを使用
- アグリゲートペイロードとドメインペイロードを組み合わせるために
IAggregatePayload
インターフェースを実装 - Orleansシリアル化のための
[GenerateSerializer]
属性を含める - ドメイン固有のプロパティをコンストラクタパラメータとして定義
- レコード内にドメインロジックメソッドを含める
コマンドはシステム状態を変更するユーザーの意図を表します。何が起こるべきかを定義します。
[GenerateSerializer]
public record InputWeatherForecastCommand(
string Location,
DateOnly Date,
int TemperatureC,
string Summary
) : ICommandWithHandler<InputWeatherForecastCommand, WeatherForecastProjector>
{
public PartitionKeys SpecifyPartitionKeys(InputWeatherForecastCommand command) =>
PartitionKeys.Generate<WeatherForecastProjector>();
public ResultBox<EventOrNone> Handle(InputWeatherForecastCommand command, ICommandContext<IAggregatePayload> context)
=> EventOrNone.Event(new WeatherForecastInputted(command.Location, command.Date, command.TemperatureC, command.Summary));
}
ポイント:
- 不変性のためにC#レコードを使用
ICommandWithHandler<TCommand, TProjector>
インターフェースを実装、または状態ベースの制約を強制する必要がある場合はICommandWithHandler<TCommand, TProjector, TPayloadType>
インターフェースを実装[GenerateSerializer]
属性を含める- アグリゲートが保存される場所を決定する
SpecifyPartitionKeys
メソッドを定義:- 新しいアグリゲートの場合:
PartitionKeys.Generate<YourProjector>()
- 既存のアグリゲートの場合:
PartitionKeys.Existing<YourProjector>(aggregateId)
- 新しいアグリゲートの場合:
- イベントを返す
Handle
メソッドを実装 - コマンドは直接状態を変更せず、イベントを生成する
型レベルで状態ベースの制約を強制するために、第3のジェネリックパラメータを指定できます:
public record RevokeUser(Guid UserId) : ICommandWithHandler<RevokeUser, UserProjector, ConfirmedUser>
{
public PartitionKeys SpecifyPartitionKeys(RevokeUser command) => PartitionKeys<UserProjector>.Existing(UserId);
public ResultBox<EventOrNone> Handle(RevokeUser command, ICommandContext<ConfirmedUser> context) =>
context
.GetAggregate()
.Conveyor(_ => EventOrNone.Event(new UserUnconfirmed()));
}
ポイント:
- 第3ジェネリックパラメータ
ConfirmedUser
は、現在のアグリゲートペイロードがConfirmedUser
型である場合にのみこのコマンドを実行できることを指定します - コマンドコンテキストは
ICommandContext<IAggregatePayload>
ではなくICommandContext<ConfirmedUser>
に強く型付けされています - これにより、状態依存の操作にコンパイル時の安全性が提供されます
- エグゼキューターはコマンドを実行する前に、現在のペイロードタイプが指定されたタイプと一致するかどうかを自動的にチェックします
- これは、エンティティの異なる状態を表現するためにアグリゲートペイロードタイプを使用する場合に特に有用です
コマンドハンドラーでアグリゲートペイロードにアクセスする方法は、2つまたは3つのジェネリックパラメータバージョンを使用するかによって2つあります:
-
型制約あり(3つのジェネリックパラメータ):
// ICommandWithHandler<TCommand, TProjector, TAggregatePayload>を使用 public ResultBox<EventOrNone> Handle(YourCommand command, ICommandContext<ConfirmedUser> context) { // 強く型付けされたアグリゲートとペイロードに直接アクセス var aggregate = context.GetAggregate(); var payload = aggregate.Payload; // すでにConfirmedUserとして型付けされている // ペイロードのプロパティを直接使用 var userName = payload.Name; return EventOrNone.Event(new YourEvent(...)); }
-
型制約なし(2つのジェネリックパラメータ):
// ICommandWithHandler<TCommand, TProjector>を使用 public ResultBox<EventOrNone> Handle(YourCommand command, ICommandContext<IAggregatePayload> context) { // ペイロードを期待される型にキャストする必要がある if (context.GetAggregate().GetPayload() is ConfirmedUser payload) { // これで型付けされたペイロードを使用できる var userName = payload.Name; return EventOrNone.Event(new YourEvent(...)); } // ペイロードが期待される型でない場合を処理 return new SomeException("ConfirmedUser状態が必要です"); }
アグリゲートが特定の状態であることが分かっている場合は、コンパイル時の安全性とよりクリーンなコードを提供する3パラメータバージョンが推奨されます。
コマンドが複数のイベントを生成する必要がある場合は、コマンドコンテキストのAppendEvent
メソッドを使用できます:
public ResultBox<EventOrNone> Handle(ComplexCommand command, ICommandContext<TAggregatePayload> context)
{
// まず、イベントを1つずつ追加する
context.AppendEvent(new FirstEventHappened(command.SomeData));
context.AppendEvent(new SecondEventHappened(command.OtherData));
// すべてのイベントが追加されたことを示すためにEventOrNone.Noneを返す
return EventOrNone.None;
// または、最後のイベントを返すこともできる
// return EventOrNone.Event(new FinalEventHappened(command.FinalData));
}
ポイント:
context.AppendEvent(eventPayload)
を使用してイベントストリームにイベントを追加する- 複数のイベントを順番に追加できる
- すべてのイベントが
AppendEvent
を使用して追加された場合はEventOrNone.None
を返す - または、その方法を好む場合は
EventOrNone.Event
を使用して最後のイベントを返す - 追加されたすべてのイベントは、追加された順序でアグリゲートに適用される
イベントは2つの主要な部分で構成されています:
-
イベントメタデータ: すべてのイベントに含まれるシステムレベルの情報:
- パーティションキー
- タイムスタンプ
- ID
- バージョン
- その他のシステムメタデータ
-
イベントペイロード: 開発者が定義するドメイン固有のデータ
イベントは不変であり、システムで発生した事実を表します。開発者はIEventPayload
を実装してイベントペイロードを定義することに注力します:
[GenerateSerializer]
public record WeatherForecastInputted(
string Location,
DateOnly Date,
int TemperatureC,
string Summary
) : IEventPayload;
ポイント:
- 不変性のためにC#レコードを使用
- ドメイン固有のイベントデータのために
IEventPayload
インターフェースを実装 [GenerateSerializer]
属性を含める- 過去形で名前を付ける(例:「Inputted」、「Updated」、「Deleted」)
- 状態変更を再構築するために必要なすべてのデータを含める
パーティションキーはデータベース内でのデータの編成方法を定義し、3つのコンポーネントで構成されています:
-
RootPartitionKey (文字列):
- マルチテナントアプリケーションでテナントキーとして使用可能
- テナントやその他の高レベルの区分でデータを分離
-
AggregateGroup (文字列):
- アグリゲートのグループを定義
- 通常、プロジェクター名と一致
- 関連するアグリゲートをまとめて整理
-
AggregateId (Guid):
- 各アグリゲートインスタンスの一意の識別子
- グループ内の特定のアグリゲートを特定するために使用
コマンドを実装する際、これらのパーティションキーは2つの方法で使用されます:
- 新しいアグリゲートの場合:
PartitionKeys.Generate<YourProjector>()
で新しいパーティションキーを生成 - 既存のアグリゲートの場合:
PartitionKeys.Existing<YourProjector>(aggregateId)
で既存のキーを使用
プロジェクターはイベントをアグリゲートに適用して現在の状態を構築します。プロジェクターの重要な機能の1つは、状態遷移を表現するためにアグリゲートペイロードの型を変更できることです。これにより、コマンドで状態に依存した振る舞いが可能になります。
以下は、ユーザー登録フローにおける状態遷移の例です:
public class UserProjector : IAggregateProjector
{
public IAggregatePayload Project(IAggregatePayload payload, IEvent ev) => (payload, ev.GetPayload()) switch
{
// 初期登録で未確認ユーザーを作成
(EmptyAggregatePayload, UserRegistered registered) => new UnconfirmedUser(registered.Name, registered.Email),
// 確認により未確認ユーザーを確認済みユーザーに変更
(UnconfirmedUser unconfirmedUser, UserConfirmed) => new ConfirmedUser(
unconfirmedUser.Name,
unconfirmedUser.Email),
// 確認解除により確認済みユーザーを未確認ユーザーに戻す
(ConfirmedUser confirmedUser, UserUnconfirmed) => new UnconfirmedUser(confirmedUser.Name, confirmedUser.Email),
_ => payload
};
}
ポイント:
IAggregateProjector
インターフェースを実装- パターンマッチングを使用して異なるイベントタイプを処理
- 状態遷移に基づいて異なるアグリゲートペイロードの型を返す:
- 状態変更によってビジネスルールを強制できる(例:確認済みユーザーのみが特定の操作を実行可能)
- コマンドは現在の状態の型をチェックして有効な操作を判断
- 型システムがコンパイル時にビジネスルールを強制
- 初期状態の作成を処理(
EmptyAggregatePayload
から) - 各状態変更に対して新しいインスタンスを作成して不変性を維持
クエリはシステムからデータを取得およびフィルタリングする方法を定義します。
[GenerateSerializer]
public record WeatherForecastQuery(string LocationContains)
: IMultiProjectionListQuery<AggregateListProjector<WeatherForecastProjector>, WeatherForecastQuery, WeatherForecastQuery.WeatherForecastRecord>
{
public static ResultBox<IEnumerable<WeatherForecastRecord>> HandleFilter(MultiProjectionState<AggregateListProjector<WeatherForecastProjector>> projection, WeatherForecastQuery query, IQueryContext context)
{
return projection.Payload.Aggregates.Where(m => m.Value.GetPayload() is WeatherForecast)
.Select(m => ((WeatherForecast)m.Value.GetPayload(), m.Value.PartitionKeys))
.Select((touple) => new WeatherForecastRecord(touple.PartitionKeys.AggregateId, touple.Item1.Location,
touple.Item1.Date, touple.Item1.TemperatureC, touple.Item1.Summary, touple.Item1.GetTemperatureF()))
.ToResultBox();
}
public static ResultBox<IEnumerable<WeatherForecastRecord>> HandleSort(IEnumerable<WeatherForecastRecord> filteredList, WeatherForecastQuery query, IQueryContext context)
{
return filteredList.OrderBy(m => m.Date).AsEnumerable().ToResultBox();
}
[GenerateSerializer]
public record WeatherForecastRecord(
Guid WeatherForecastId,
string Location,
DateOnly Date,
int TemperatureC,
string Summary,
int TemperatureF
);
}
ポイント:
- 適切なクエリインターフェース(例:
IMultiProjectionListQuery
)を実装 - フィルタとソートのメソッドを定義
- クエリ結果用のネストされたレコードを作成
- フィルタリングとソートにLINQを使用
- 結果を
ResultBox
でラップして返す
AOTコンパイルとパフォーマンスのために、JSONシリアル化コンテキストを定義します。
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(EventDocumentCommon))]
[JsonSerializable(typeof(EventDocumentCommon[]))]
[JsonSerializable(typeof(EventDocument<OrleansSekiban.Domain.WeatherForecastInputted>))]
[JsonSerializable(typeof(OrleansSekiban.Domain.WeatherForecastInputted))]
[JsonSerializable(typeof(EventDocument<OrleansSekiban.Domain.WeatherForecastDeleted>))]
[JsonSerializable(typeof(OrleansSekiban.Domain.WeatherForecastDeleted))]
[JsonSerializable(typeof(EventDocument<OrleansSekiban.Domain.WeatherForecastLocationUpdated>))]
[JsonSerializable(typeof(OrleansSekiban.Domain.WeatherForecastLocationUpdated))]
public partial class OrleansSekibanDomainEventsJsonContext : JsonSerializerContext
{
}
ポイント:
- シリアル化が必要なすべてのイベントタイプを含める
[JsonSourceGenerationOptions]
を使用してシリアル化を設定- 部分クラスとして定義
典型的なSekibanイベントソーシングプロジェクトは以下の構造に従います:
YourProject.Domain/
├── Aggregates/
│ └── YourAggregate.cs
├── Commands/
│ ├── CreateYourAggregateCommand.cs
│ ├── UpdateYourAggregateCommand.cs
│ └── DeleteYourAggregateCommand.cs
├── Events/
│ ├── YourAggregateCreated.cs
│ ├── YourAggregateUpdated.cs
│ └── YourAggregateDeleted.cs
├── Projectors/
│ └── YourAggregateProjector.cs
├── Queries/
│ └── YourAggregateQuery.cs
└── YourProjectDomainEventsJsonContext.cs
Sekibanイベントソーシングプロジェクトを扱う際:
-
ドメインモデルを理解する:
- 主要なアグリゲートとその関係を特定する
- ビジネスルールと制約を理解する
-
イベントソーシングパターンに従う:
- コマンドは検証してイベントを生成する
- イベントは不変であり、事実を表す
- 状態はイベントから導き出される
- クエリは投影された状態から読み取る
-
命名規則:
- コマンド:命令形動詞(Create、Update、Delete)
- イベント:過去形動詞(Created、Updated、Deleted)
- アグリゲート:ドメインエンティティを表す名詞
- プロジェクター:投影するアグリゲートにちなんで命名
-
コード生成:
- Orleansシリアル化のための
[GenerateSerializer]
属性を使用 - 各コンポーネントに適切なインターフェースを実装
- 不変性のためにC#レコードを使用
- Orleansシリアル化のための
-
テスト:
- コマンドが生成するイベントを検証してテスト
- イベントを適用して結果の状態をチェックしてプロジェクターをテスト
- テストデータを設定して結果を検証してクエリをテスト
- インメモリまたはOrleansベースのテスト用の組み込みテストフレームワークを使用
- 流暢なテストアサーションのためにResultBoxを使用したメソッドチェーンを活用
-
エラー処理:
ResultBox
を使用してエラーを処理し、意味のあるメッセージを返す- イベントを生成する前にコマンドを検証
- プロジェクターでエッジケースを処理
Sekibanは、イベントソースアプリケーションのユニットテストをインメモリとOrleansベースの両方のテストフレームワークでサポートしています。
シンプルなユニットテストには、Sekiban.Pure.xUnit
名前空間のSekibanInMemoryTestBase
クラスを使用できます:
public class YourTests : SekibanInMemoryTestBase
{
// ドメインタイプを提供するためにオーバーライド
protected override SekibanDomainTypes GetDomainTypes() =>
YourDomainTypes.Generate(YourEventsJsonContext.Default.Options);
[Fact]
public void SimpleTest()
{
// Given - コマンドを実行してレスポンスを取得
var response1 = GivenCommand(new CreateYourEntity("Name", "Value"));
Assert.Equal(1, response1.Version);
// When - 同じアグリゲートに対して別のコマンドを実行
var response2 = WhenCommand(new UpdateYourEntity(response1.PartitionKeys.AggregateId, "NewValue"));
Assert.Equal(2, response2.Version);
// Then - アグリゲートを取得して状態を検証
var aggregate = ThenGetAggregate<YourEntityProjector>(response2.PartitionKeys);
var entity = (YourEntity)aggregate.Payload;
Assert.Equal("NewValue", entity.Value);
// Then - クエリを実行して結果を検証
var queryResult = ThenQuery(new YourEntityExistsQuery("Name"));
Assert.True(queryResult);
}
}
ベースクラスはGiven-When-Thenパターンに従うメソッドを提供します:
GivenCommand
- コマンドを実行して初期状態を設定WhenCommand
- テスト対象のコマンドを実行ThenGetAggregate
- アグリゲートを取得して状態を検証ThenQuery
- クエリを実行して結果を検証
より流暢で読みやすいテストのために、メソッドチェーンをサポートするResultBoxベースのメソッドを使用できます:
[Fact]
public void ChainedTest()
=> GivenCommandWithResult(new CreateYourEntity("Name", "Value"))
.Do(response => Assert.Equal(1, response.Version))
.Conveyor(response => WhenCommandWithResult(new UpdateYourEntity(response.PartitionKeys.AggregateId, "NewValue")))
.Do(response => Assert.Equal(2, response.Version))
.Conveyor(response => ThenGetAggregateWithResult<YourEntityProjector>(response.PartitionKeys))
.Conveyor(aggregate => aggregate.Payload.ToResultBox().Cast<YourEntity>())
.Do(payload => Assert.Equal("NewValue", payload.Value))
.Conveyor(_ => ThenQueryWithResult(new YourEntityExistsQuery("Name")))
.Do(Assert.True)
.UnwrapBox();
ポイント:
Conveyor
は一つの操作の結果を次の入力に変換Do
はアサーションやサイドエフェクトを実行し、結果を変更しないUnwrapBox
は最終的なResultBoxをアンラップし、いずれかのステップが失敗した場合は例外をスロー
Orleans統合のテストには、Sekiban.Pure.Orleans.xUnit
名前空間のSekibanOrleansTestBase
クラスを使用します:
public class YourOrleansTests : SekibanOrleansTestBase<YourOrleansTests>
{
public override SekibanDomainTypes GetDomainTypes() =>
YourDomainTypes.Generate(YourEventsJsonContext.Default.Options);
[Fact]
public void OrleansTest() =>
GivenCommandWithResult(new CreateYourEntity("Name", "Value"))
.Do(response => Assert.Equal(1, response.Version))
.Conveyor(response => WhenCommandWithResult(new UpdateYourEntity(response.PartitionKeys.AggregateId, "NewValue")))
.Do(response => Assert.Equal(2, response.Version))
.Conveyor(response => ThenGetAggregateWithResult<YourEntityProjector>(response.PartitionKeys))
.Conveyor(aggregate => aggregate.Payload.ToResultBox().Cast<YourEntity>())
.Do(payload => Assert.Equal("NewValue", payload.Value))
.Conveyor(_ => ThenGetMultiProjectorWithResult<AggregateListProjector<YourEntityProjector>>())
.Do(projector =>
{
Assert.Equal(1, projector.Aggregates.Values.Count());
var entity = (YourEntity)projector.Aggregates.Values.First().Payload;
Assert.Equal("NewValue", entity.Value);
})
.UnwrapBox();
[Fact]
public void TestSerializable()
{
// コマンドがシリアル化可能であることをテスト(Orleansでは重要)
CheckSerializability(new CreateYourEntity("Name", "Value"));
}
}
Orleansテストベースクラスはインメモリテストベースクラスと同様のメソッドを提供しますが、より現実的なテストのために完全なOrleansテストクラスターをセットアップします。
より複雑なシナリオやカスタムテストセットアップには、InMemorySekibanExecutor
を手動で作成できます:
[Fact]
public async Task ManualExecutorTest()
{
// インメモリエグゼキューターを作成
var executor = new InMemorySekibanExecutor(
YourDomainTypes.Generate(YourEventsJsonContext.Default.Options),
new FunctionCommandMetadataProvider(() => "test"),
new Repository(),
new ServiceCollection().BuildServiceProvider());
// コマンドを実行
var result = await executor.CommandAsync(new CreateYourEntity("Name", "Value"));
Assert.True(result.IsSuccess);
var value = result.GetValue();
Assert.NotNull(value);
Assert.Equal(1, value.Version);
var aggregateId = value.PartitionKeys.AggregateId;
// アグリゲートをロード
var aggregateResult = await executor.LoadAggregateAsync<YourEntityProjector>(
PartitionKeys.Existing<YourEntityProjector>(aggregateId));
Assert.True(aggregateResult.IsSuccess);
var aggregate = aggregateResult.GetValue();
var entity = (YourEntity)aggregate.Payload;
Assert.Equal("Name", entity.Name);
Assert.Equal("Value", entity.Value);
}
- コマンドのテスト: コマンドが期待されるイベントと状態変更を生成することを検証
- プロジェクターのテスト: プロジェクターがイベントを正しく適用してアグリゲート状態を構築することを検証
- クエリのテスト: クエリが現在の状態に基づいて期待される結果を返すことを検証
- 状態遷移のテスト: 特に異なるペイロードタイプを使用する場合、状態遷移が正しく機能することを検証
- エラーケースのテスト: 検証が失敗した場合にコマンドが適切に失敗することを検証
- シリアル化のテスト: Orleansテストでは、コマンドとイベントがシリアル化可能であることを検証
テンプレートから始めます:
dotnet new install Sekiban.Pure.Templates
dotnet new sekiban-orleans-aspire -n MyProject
テンプレートは必要なすべての設定を含むProgram.csを生成します。以下はその仕組みです:
var builder = WebApplication.CreateBuilder(args);
// AspireとOrleans統合を追加
builder.AddServiceDefaults();
builder.UseOrleans(config =>
{
config.UseDashboard(options => { });
config.AddMemoryStreams("EventStreamProvider")
.AddMemoryGrainStorage("EventStreamProvider");
});
// ドメインタイプとシリアル化を登録
builder.Services.AddSingleton(
OrleansSekibanDomainDomainTypes.Generate(
OrleansSekibanDomainEventsJsonContext.Default.Options));
// データベース(Cosmos DBまたはPostgreSQL)を設定
if (builder.Configuration.GetSection("Sekiban").GetValue<string>("Database")?.ToLower() == "cosmos")
{
builder.AddSekibanCosmosDb();
} else
{
builder.AddSekibanPostgresDb();
}
コマンドとクエリのエンドポイントをマッピングします:
// クエリエンドポイント
apiRoute.MapGet("/weatherforecast",
async ([FromServices]SekibanOrleansExecutor executor) =>
{
var list = await executor.QueryAsync(new WeatherForecastQuery(""))
.UnwrapBox();
return list.Items;
})
.WithOpenApi();
// コマンドエンドポイント
apiRoute.MapPost("/inputweatherforecast",
async (
[FromBody] InputWeatherForecastCommand command,
[FromServices] SekibanOrleansExecutor executor) =>
await executor.CommandAsync(command).UnwrapBox())
.WithOpenApi();
ポイント:
- コマンドとクエリの処理には
SekibanOrleansExecutor
を使用 - コマンドはPOSTエンドポイントにマッピング
- クエリは通常GETエンドポイントにマッピング
- 結果は
UnwrapBox()
を使用してResultBox
からアンラップ - OpenAPIサポートはデフォルトで含まれる
- プロジェクトテンプレートから始める
- ドメインモデル(アグリゲート)を定義
- ユーザーの意図を表すコマンドを作成
- 状態変更を表すイベントを定義
- イベントをアグリゲートに適用するプロジェクターを実装
- データを取得およびフィルタリングするクエリを作成
- JSONシリアル化コンテキストを設定
SekibanOrleansExecutor
を使用してAPIエンドポイントをマッピング
テンプレートは2つのデータベースオプションをサポートしています:
{
"Sekiban": {
"Database": "Cosmos" // または "Postgres"
}
}
ドメイン用のWebフロントエンドを実装するには:
- WebプロジェクトでAPIクライアントを作成します:
public class YourApiClient(HttpClient httpClient)
{
public async Task<YourQuery.ResultRecord[]> GetItemsAsync(
CancellationToken cancellationToken = default)
{
List<YourQuery.ResultRecord>? items = null;
await foreach (var item in httpClient.GetFromJsonAsAsyncEnumerable<YourQuery.ResultRecord>("/api/items", cancellationToken))
{
if (item is not null)
{
items ??= [];
items.Add(item);
}
}
return items?.ToArray() ?? [];
}
public async Task CreateItemAsync(
string param1,
string param2,
CancellationToken cancellationToken = default)
{
var command = new CreateYourItemCommand(param1, param2);
await httpClient.PostAsJsonAsync("/api/createitem", command, cancellationToken);
}
}
- Program.csでAPIクライアントを登録します:
builder.Services.AddHttpClient<YourApiClient>(client =>
{
client.BaseAddress = new("https+http://apiservice");
});
- ドメインとやり取りするためのRazorページを作成します
Sekibanはビルド時にドメインタイプ登録を作成するためにソース生成を使用します。これはドメインモデル登録を簡素化し、型安全性を確保するフレームワークの重要な部分です。
// このクラスはSekiban.Pure.SourceGeneratorによって自動生成されます
// 手動で作成する必要はありません
public static class YourProjectDomainDomainTypes
{
// DIコンテナにドメインタイプを登録するために使用
public static SekibanDomainTypes Generate(JsonSerializerOptions options) =>
// 実装はドメインモデルに基づいて生成される
...
// シリアル化チェックに使用
public static SekibanDomainTypes Generate() =>
Generate(new JsonSerializerOptions());
}
-
命名規則:
- 生成されるクラスは
[ProjectName]DomainDomainTypes
パターンに従います - 例えば、「SchoolManagement」という名前のプロジェクトは
SchoolManagementDomainDomainTypes
を持ちます
- 生成されるクラスは
-
名前空間:
- 生成されるクラスは
[ProjectName].Generated
名前空間に配置されます - 例えば、
SchoolManagement.Domain.Generated
- 生成されるクラスは
-
アプリケーションでの使用法:
// Program.csで builder.Services.AddSingleton( YourProjectDomainDomainTypes.Generate( YourProjectDomainEventsJsonContext.Default.Options));
-
テストでの使用法:
// テストクラスで protected override SekibanDomainTypes GetDomainTypes() => YourProjectDomainDomainTypes.Generate( YourProjectDomainEventsJsonContext.Default.Options);
-
テストに必要なインポート:
using YourProject.Domain; using YourProject.Domain.Generated; // 生成された型を含む using Sekiban.Pure; using Sekiban.Pure.xUnit;
-
生成された型が見つからない:
- テストを実行する前にプロジェクトが正常にビルドされていることを確認
- すべてのドメイン型に必要な属性があることを確認
- ソース生成に関連するビルド警告を確認
-
名前空間エラー:
- 正しい生成された名前空間をインポートしていることを確認
- 名前空間はソースファイルでは表示されず、コンパイルされたアセンブリでのみ表示される
-
型が見つからないエラー:
- 正しい命名規則を使用していることを確認
- クラス名の入力ミスを確認
-
テストのベストプラクティス:
- 常にソース生成された型を直接参照する
- テスト用に独自のドメイン型クラスを作成しない
- メインアプリケーションと同じJsonSerializerOptionsを使用する
Sekibanは、複数のアグリゲートにまたがるビジネスロジックや特殊な処理を必要とするビジネスロジックをカプセル化するドメインワークフローとサービスの実装をサポートしています。
ドメインワークフローは、複数のアグリゲートを含むビジネスプロセスや複雑な検証ロジックを実装するステートレスサービスです。特に以下の場合に有用です:
- クロスアグリゲート操作: ビジネスプロセスが複数のアグリゲートにまたがる場合
- 複雑な検証: 検証が複数のアグリゲートや外部システムに対するチェックを必要とする場合
- 再利用可能なビジネスロジック: 同じロジックが複数の場所で使用される場合
// 重複チェック用のドメインワークフローの例
namespace YourProject.Domain.Workflows;
public static class DuplicateCheckWorkflows
{
// 重複チェック操作の結果型
public class DuplicateCheckResult
{
public bool IsDuplicate { get; }
public string? ErrorMessage { get; }
public object? CommandResult { get; }
private DuplicateCheckResult(bool isDuplicate, string? errorMessage, object? commandResult)
{
IsDuplicate = isDuplicate;
ErrorMessage = errorMessage;
CommandResult = commandResult;
}
public static DuplicateCheckResult Duplicate(string errorMessage) =>
new(true, errorMessage, null);
public static DuplicateCheckResult Success(object commandResult) =>
new(false, null, commandResult);
}
// 登録前にIDの重複をチェックするワークフローメソッド
public static async Task<DuplicateCheckResult> CheckUserIdDuplicate(
RegisterUserCommand command,
ISekibanExecutor executor)
{
// userIdが既に存在するかチェック
var userIdExists = await executor.QueryAsync(new UserIdExistsQuery(command.UserId)).UnwrapBox();
if (userIdExists)
{
return DuplicateCheckResult.Duplicate($"ID '{command.UserId}' を持つユーザーは既に存在します");
}
// 重複がなければコマンドを実行
var result = await executor.CommandAsync(command).UnwrapBox();
return DuplicateCheckResult.Success(result);
}
}
ポイント:
- ワークフローは通常、静的クラスと静的メソッドとして実装されます
Workflows
フォルダーまたは名前空間に配置する必要があります- より良いテスト可能性のために
ISekibanExecutor
インターフェースを使用する必要があります - 成功/失敗情報をカプセル化するドメイン固有の結果型を返す必要があります
- APIエンドポイントや他のサービスから呼び出すことができます
// Program.csで
apiRoute.MapPost("/users/register",
async ([FromBody] RegisterUserCommand command, [FromServices] SekibanOrleansExecutor executor) =>
{
// 重複をチェックするためにワークフローを使用
var result = await DuplicateCheckWorkflows.CheckUserIdDuplicate(command, executor);
if (result.IsDuplicate)
{
return Results.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "ユーザーIDの重複",
detail: result.ErrorMessage);
}
return result.CommandResult;
});
ワークフローは、他のSekibanコンポーネントと同じインメモリテストアプローチを使用してテストできます:
public class DuplicateCheckWorkflowsTests : SekibanInMemoryTestBase
{
protected override SekibanDomainTypes GetDomainTypes() =>
YourDomainDomainTypes.Generate(YourDomainEventsJsonContext.Default.Options);
[Fact]
public async Task CheckUserIdDuplicate_WhenUserIdExists_ReturnsDuplicate()
{
// Arrange - テストしたいIDを持つユーザーを作成
var existingUserId = "U12345";
var command = new RegisterUserCommand(
"John Doe",
existingUserId,
"john@example.com");
// 同じIDを持つユーザーが存在することを確認するために登録
GivenCommand(command);
// Act - 同じIDで別のユーザーを登録しようとする
var result = await DuplicateCheckWorkflows.CheckUserIdDuplicate(command, Executor);
// Assert
Assert.True(result.IsDuplicate);
Assert.Contains(existingUserId, result.ErrorMessage);
Assert.Null(result.CommandResult);
}
[Fact]
public async Task CheckUserIdDuplicate_WhenUserIdDoesNotExist_ReturnsSuccess()
{
// Arrange
var newUserId = "U67890";
var command = new RegisterUserCommand(
"Jane Doe",
newUserId,
"jane@example.com");
// Act
var result = await DuplicateCheckWorkflows.CheckUserIdDuplicate(command, Executor);
// Assert
Assert.False(result.IsDuplicate);
Assert.Null(result.ErrorMessage);
Assert.NotNull(result.CommandResult);
}
}
ポイント:
- ワークフローのテストには
SekibanInMemoryTestBase
を使用 - ベースクラスは
ISekibanExecutor
を実装するExecutor
プロパティを提供 - テスト状態を設定するには
GivenCommand
を使用 - 成功と失敗の両方のシナリオをテスト
-
名前空間エラー:
Sekiban.Core.*
ではなくSekiban.Pure.*
名前空間を使用していることを確認してください。 -
コマンドコンテキスト: コマンドコンテキストはアグリゲートペイロードを直接公開しません。アグリゲート状態をチェックする必要がある場合は、コマンドハンドラーでパターンマッチングを使用してください:
if (context.AggregatePayload is YourAggregate aggregate) { // アグリゲートプロパティを使用 }
-
アプリケーションの実行: Aspireホストでアプリケーションを実行するには、次のコマンドを使用します:
dotnet run --project MyProject.AppHost
HTTPSプロファイルでAppHostを起動するには、次を使用します:
dotnet run --project MyProject.AppHost --launch-profile https
これにより、アプリケーションがHTTPSを使用して安全に通信することが保証されます。これは特に本番環境で重要です。
-
Webフロントエンドへのアクセス: WebフロントエンドはAspireダッシュボードに表示されるURLで利用可能で、通常は
https://localhost:XXXXX
のようなURLです。 -
ISekibanExecutor vs. SekibanOrleansExecutor: ドメインサービスやワークフローを実装する場合、より良いテスト可能性のために具体的な
SekibanOrleansExecutor
クラスではなくISekibanExecutor
インターフェースを使用してください。ISekibanExecutor
インターフェースはSekiban.Pure.Executors
名前空間にあります。
Sekibanは.NETアプリケーションでイベントソーシングを実装するための強力なフレームワークを提供します。このガイドで概説されている主要コンポーネントを理解し、ベストプラクティスに従うことで、LLMプログラミングエージェントはSekibanイベントソーシングプロジェクトを効果的に作成および維持できます。