For LLM 日本語版はこちら (Japanese Version)
This guide explains how to create and work with event sourcing projects using Sekiban, based on the template structure in templates/Sekiban.Pure.Templates/content/Sekiban.Orleans.Aspire/OrleansSekiban.Domain
.
To quickly create a new Sekiban project with Orleans and Aspire integration:
# Install the Sekiban templates
dotnet new install Sekiban.Pure.Templates
# Create a new project
dotnet new sekiban-orleans-aspire -n MyProject
This template includes:
- .NET Aspire host for Orleans
- Cluster Storage
- Grain Persistent Storage
- Queue Storage
Event Sourcing is a design pattern where:
- All changes to application state are stored as a sequence of events
- These events are the source of truth
- The current state is derived by replaying events
- Events are immutable and represent facts that happened in the system
Sekiban is a .NET event sourcing framework that:
- Simplifies implementing event sourcing in C# applications
- Provides integration with Orleans for distributed systems
- Supports various storage backends
- Offers a clean, type-safe API for defining domain models
An aggregate consists of two main parts:
-
Aggregate Payload: Basic information that exists in every aggregate, such as:
- Current version
- Last event ID
- Other system-level metadata
-
Payload: The domain-specific data defined by developers.
In Sekiban, aggregates implement IAggregatePayload
:
[GenerateSerializer]
public record WeatherForecast(
string Location,
DateOnly Date,
int TemperatureC,
string Summary
) : IAggregatePayload
{
public int GetTemperatureF()
{
return 32 + (int)(TemperatureC / 0.5556);
}
}
Key points:
- Use C# records for immutability
- Implement
IAggregatePayload
interface for combining both aggregate payload and domain payload - Include the
[GenerateSerializer]
attribute for Orleans serialization - Define domain-specific properties as constructor parameters
- Include domain logic methods within the record
Commands represent user intentions to change the system state. They define what should happen.
[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));
}
Key points:
- Use C# records for immutability
- Implement
ICommandWithHandler<TCommand, TProjector>
interface orICommandWithHandler<TCommand, TProjector, TPayloadType>
interface when you need to enforce state-based constraints - Include the
[GenerateSerializer]
attribute - Define
SpecifyPartitionKeys
method to determine where the aggregate is stored:- For new aggregates:
PartitionKeys.Generate<YourProjector>()
- For existing aggregates:
PartitionKeys.Existing<YourProjector>(aggregateId)
- For new aggregates:
- Implement
Handle
method that returns events or no events - Commands don't modify state directly; they produce events
You can specify a third generic parameter to enforce state-based constraints at the type level:
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()));
}
Key points:
- The third generic parameter
ConfirmedUser
specifies that this command can only be executed when the current aggregate payload is of typeConfirmedUser
- The command context is now strongly typed to
ICommandContext<ConfirmedUser>
instead ofICommandContext<IAggregatePayload>
- This provides compile-time safety for state-dependent operations
- The executor will automatically check if the current payload type matches the specified type before executing the command
- This is particularly useful when using aggregate payload types to express different states of an entity
There are two ways to access the aggregate payload in command handlers, depending on whether you use the two or three generic parameter version:
-
With Type Constraint (Three Generic Parameters):
// Using ICommandWithHandler<TCommand, TProjector, TAggregatePayload> public ResultBox<EventOrNone> Handle(YourCommand command, ICommandContext<ConfirmedUser> context) { // Direct access to strongly-typed aggregate and payload var aggregate = context.GetAggregate(); var payload = aggregate.Payload; // Already typed as ConfirmedUser // Use payload properties directly var userName = payload.Name; return EventOrNone.Event(new YourEvent(...)); }
-
Without Type Constraint (Two Generic Parameters):
// Using ICommandWithHandler<TCommand, TProjector> public ResultBox<EventOrNone> Handle(YourCommand command, ICommandContext<IAggregatePayload> context) { // Need to cast the payload to the expected type if (context.GetAggregate().GetPayload() is ConfirmedUser payload) { // Now you can use the typed payload var userName = payload.Name; return EventOrNone.Event(new YourEvent(...)); } // Handle case where payload is not of expected type return new SomeException("Expected ConfirmedUser state"); }
The three-parameter version is preferred when you know the exact state the aggregate should be in, as it provides compile-time safety and cleaner code.
If a command needs to generate multiple events, you can use the AppendEvent
method on the command context:
public ResultBox<EventOrNone> Handle(ComplexCommand command, ICommandContext<TAggregatePayload> context)
{
// First, append events one by one
context.AppendEvent(new FirstEventHappened(command.SomeData));
context.AppendEvent(new SecondEventHappened(command.OtherData));
// Then return EventOrNone.None to indicate that all events have been appended
return EventOrNone.None;
// Alternatively, you can return the last event
// return EventOrNone.Event(new FinalEventHappened(command.FinalData));
}
Key points:
- Use
context.AppendEvent(eventPayload)
to add events to the event stream - You can append multiple events in sequence
- Return
EventOrNone.None
if all events have been appended usingAppendEvent
- Or return the last event using
EventOrNone.Event
if you prefer that approach - All appended events will be applied to the aggregate in the order they were added
Events consist of two main parts:
-
Event Metadata: System-level information included in every event:
- PartitionKeys
- Timestamp
- Id
- Version
- Other system metadata
-
Event Payload: The domain-specific data defined by developers.
Events are immutable and represent facts that have happened in the system. Developers focus on defining the Event Payload by implementing IEventPayload
:
[GenerateSerializer]
public record WeatherForecastInputted(
string Location,
DateOnly Date,
int TemperatureC,
string Summary
) : IEventPayload;
Key points:
- Use C# records for immutability
- Implement
IEventPayload
interface for the domain-specific event data - Include the
[GenerateSerializer]
attribute - Name events in past tense (e.g., "Inputted", "Updated", "Deleted")
- Include all data needed to reconstruct the state change
PartitionKeys define how data is organized in the database and consist of three components:
-
RootPartitionKey (string):
- Can be used as a Tenant Key in multi-tenant applications
- Helps segregate data by tenant or other high-level divisions
-
AggregateGroup (string):
- Defines a group of aggregates
- Usually matches the projector name
- Used to organize related aggregates together
-
AggregateId (Guid):
- Unique identifier for each aggregate instance
- Used to locate specific aggregates within a group
When implementing commands, you use these partition keys in two ways:
- For new aggregates:
PartitionKeys.Generate<YourProjector>()
generates new partition keys - For existing aggregates:
PartitionKeys.Existing<YourProjector>(aggregateId)
uses existing keys
Projectors apply events to aggregates to build the current state. A key feature of projectors is their ability to change the aggregate payload type to express state transitions, which enables state-dependent behavior in commands.
Here's an example showing state transitions in a user registration flow:
public class UserProjector : IAggregateProjector
{
public IAggregatePayload Project(IAggregatePayload payload, IEvent ev) => (payload, ev.GetPayload()) switch
{
// Initial registration creates an UnconfirmedUser
(EmptyAggregatePayload, UserRegistered registered) => new UnconfirmedUser(registered.Name, registered.Email),
// Confirmation changes UnconfirmedUser to ConfirmedUser
(UnconfirmedUser unconfirmedUser, UserConfirmed) => new ConfirmedUser(
unconfirmedUser.Name,
unconfirmedUser.Email),
// Unconfirmation changes ConfirmedUser back to UnconfirmedUser
(ConfirmedUser confirmedUser, UserUnconfirmed) => new UnconfirmedUser(confirmedUser.Name, confirmedUser.Email),
_ => payload
};
}
Key points:
- Implement
IAggregateProjector
interface - Use pattern matching to handle different event types
- Return different aggregate payload types based on state transitions:
- State changes can enforce business rules (e.g., only confirmed users can perform certain actions)
- Commands can check the current state type to determine valid operations
- The type system helps enforce business rules at compile time
- Handle the initial state creation (from
EmptyAggregatePayload
) - Maintain immutability by creating new instances for each state change
Queries define how to retrieve and filter data from the system.
[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
);
}
Key points:
- Implement appropriate query interface (e.g.,
IMultiProjectionListQuery
) - Define filter and sort methods
- Create a nested record for query results
- Use LINQ for filtering and sorting
- Return results wrapped in
ResultBox
For AOT compilation and performance, define a JSON serialization context.
[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
{
}
Key points:
- Include all event types that need serialization
- Use
[JsonSourceGenerationOptions]
to configure serialization - Define as a partial class
A typical Sekiban event sourcing project follows this structure:
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
When working with Sekiban event sourcing projects:
-
Understand the Domain Model:
- Identify the key aggregates and their relationships
- Understand the business rules and constraints
-
Follow the Event Sourcing Pattern:
- Commands validate and produce events
- Events are immutable and represent facts
- State is derived from events
- Queries read from the projected state
-
Naming Conventions:
- Commands: Imperative verbs (Create, Update, Delete)
- Events: Past tense verbs (Created, Updated, Deleted)
- Aggregates: Nouns representing domain entities
- Projectors: Named after the aggregate they project
-
Code Generation:
- Use the
[GenerateSerializer]
attribute for Orleans serialization - Implement the appropriate interfaces for each component
- Use C# records for immutability
- Use the
-
Testing:
- Test commands by verifying the events they produce
- Test projectors by applying events and checking the resulting state
- Test queries by setting up test data and verifying the results
- Use the built-in testing frameworks for in-memory or Orleans-based testing
- Leverage method chaining with ResultBox for fluent test assertions
-
Error Handling:
- Use
ResultBox
to handle errors and return meaningful messages - Validate commands before producing events
- Handle edge cases in projectors
- Use
Sekiban provides robust support for unit testing your event-sourced applications with both in-memory and Orleans-based testing frameworks.
For simple unit tests, you can use the SekibanInMemoryTestBase
class from the Sekiban.Pure.xUnit
namespace:
public class YourTests : SekibanInMemoryTestBase
{
// Override to provide your domain types
protected override SekibanDomainTypes GetDomainTypes() =>
YourDomainTypes.Generate(YourEventsJsonContext.Default.Options);
[Fact]
public void SimpleTest()
{
// Given - Execute a command and get the response
var response1 = GivenCommand(new CreateYourEntity("Name", "Value"));
Assert.Equal(1, response1.Version);
// When - Execute another command on the same aggregate
var response2 = WhenCommand(new UpdateYourEntity(response1.PartitionKeys.AggregateId, "NewValue"));
Assert.Equal(2, response2.Version);
// Then - Get the aggregate and verify its state
var aggregate = ThenGetAggregate<YourEntityProjector>(response2.PartitionKeys);
var entity = (YourEntity)aggregate.Payload;
Assert.Equal("NewValue", entity.Value);
// Then - Execute a query and verify the result
var queryResult = ThenQuery(new YourEntityExistsQuery("Name"));
Assert.True(queryResult);
}
}
The base class provides methods that follow the Given-When-Then pattern:
GivenCommand
- Sets up the initial state by executing a commandWhenCommand
- Executes the command being testedThenGetAggregate
- Retrieves an aggregate to verify its stateThenQuery
- Executes a query to verify the result
For more fluent and readable tests, you can use the ResultBox-based methods that support method chaining:
[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();
Key points:
Conveyor
transforms the result of one operation into the input for the nextDo
performs assertions or side effects without changing the resultUnwrapBox
unwraps the final ResultBox, throwing an exception if any step failed
For testing with Orleans integration, use the SekibanOrleansTestBase
class from the Sekiban.Pure.Orleans.xUnit
namespace:
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()
{
// Test that commands are serializable (important for Orleans)
CheckSerializability(new CreateYourEntity("Name", "Value"));
}
}
The Orleans test base class provides similar methods to the in-memory test base class but sets up a complete Orleans test cluster for more realistic testing.
For more complex scenarios or custom test setups, you can manually create an InMemorySekibanExecutor
:
[Fact]
public async Task ManualExecutorTest()
{
// Create an in-memory executor
var executor = new InMemorySekibanExecutor(
YourDomainTypes.Generate(YourEventsJsonContext.Default.Options),
new FunctionCommandMetadataProvider(() => "test"),
new Repository(),
new ServiceCollection().BuildServiceProvider());
// Execute a command
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;
// Load the aggregate
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);
}
- Test Commands: Verify that commands produce the expected events and state changes
- Test Projectors: Verify that projectors correctly apply events to build the aggregate state
- Test Queries: Verify that queries return the expected results based on the current state
- Test State Transitions: Verify that state transitions work correctly, especially when using different payload types
- Test Error Cases: Verify that commands fail appropriately when validation fails
- Test Serialization: For Orleans tests, verify that commands and events are serializable
Start with the template:
dotnet new install Sekiban.Pure.Templates
dotnet new sekiban-orleans-aspire -n MyProject
The template generates a Program.cs with all necessary configurations. Here's how it works:
var builder = WebApplication.CreateBuilder(args);
// Add Aspire and Orleans integration
builder.AddServiceDefaults();
builder.UseOrleans(config =>
{
config.UseDashboard(options => { });
config.AddMemoryStreams("EventStreamProvider")
.AddMemoryGrainStorage("EventStreamProvider");
});
// Register domain types and serialization
builder.Services.AddSingleton(
OrleansSekibanDomainDomainTypes.Generate(
OrleansSekibanDomainEventsJsonContext.Default.Options));
// Configure database (Cosmos DB or PostgreSQL)
if (builder.Configuration.GetSection("Sekiban").GetValue<string>("Database")?.ToLower() == "cosmos")
{
builder.AddSekibanCosmosDb();
} else
{
builder.AddSekibanPostgresDb();
}
Map endpoints for commands and queries:
// Query endpoint
apiRoute.MapGet("/weatherforecast",
async ([FromServices]SekibanOrleansExecutor executor) =>
{
var list = await executor.QueryAsync(new WeatherForecastQuery(""))
.UnwrapBox();
return list.Items;
})
.WithOpenApi();
// Command endpoint
apiRoute.MapPost("/inputweatherforecast",
async (
[FromBody] InputWeatherForecastCommand command,
[FromServices] SekibanOrleansExecutor executor) =>
await executor.CommandAsync(command).UnwrapBox())
.WithOpenApi();
Key points:
- Use
SekibanOrleansExecutor
for handling commands and queries - Commands are mapped to POST endpoints
- Queries are typically mapped to GET endpoints
- Results are unwrapped from
ResultBox
usingUnwrapBox()
- OpenAPI support is included by default
- Start with the project template
- Define your domain model (aggregates)
- Create commands that represent user intentions
- Define events that represent state changes
- Implement projectors to apply events to aggregates
- Create queries to retrieve and filter data
- Set up the JSON serialization context
- Map your API endpoints using the
SekibanOrleansExecutor
The template supports two database options:
{
"Sekiban": {
"Database": "Cosmos" // or "Postgres"
}
}
To implement a web frontend for your domain:
- Create an API client in the Web project:
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);
}
}
- Register the API client in Program.cs:
builder.Services.AddHttpClient<YourApiClient>(client =>
{
client.BaseAddress = new("https+http://apiservice");
});
- Create Razor pages to interact with your domain
Sekiban uses source generation to create domain type registrations at build time. This is a key part of the framework that simplifies domain model registration and ensures type safety.
// This class is automatically generated by Sekiban.Pure.SourceGenerator
// You don't need to create it manually
public static class YourProjectDomainDomainTypes
{
// Used for registering domain types with the DI container
public static SekibanDomainTypes Generate(JsonSerializerOptions options) =>
// Implementation is generated based on your domain model
...
// Used for serialization checking
public static SekibanDomainTypes Generate() =>
Generate(new JsonSerializerOptions());
}
-
Naming Convention:
- The generated class follows the pattern
[ProjectName]DomainDomainTypes
- For example, a project named "SchoolManagement" will have
SchoolManagementDomainDomainTypes
- The generated class follows the pattern
-
Namespace:
- The generated class is placed in the
[ProjectName].Generated
namespace - For example,
SchoolManagement.Domain.Generated
- The generated class is placed in the
-
Usage in Application:
// In Program.cs builder.Services.AddSingleton( YourProjectDomainDomainTypes.Generate( YourProjectDomainEventsJsonContext.Default.Options));
-
Usage in Tests:
// In test classes protected override SekibanDomainTypes GetDomainTypes() => YourProjectDomainDomainTypes.Generate( YourProjectDomainEventsJsonContext.Default.Options);
-
Required Imports for Tests:
using YourProject.Domain; using YourProject.Domain.Generated; // Contains the generated types using Sekiban.Pure; using Sekiban.Pure.xUnit;
-
Missing Generated Types:
- Ensure the project builds successfully before running tests
- Check that all domain types have the required attributes
- Look for build warnings related to source generation
-
Namespace Errors:
- Make sure to import the correct Generated namespace
- The namespace is not visible in source files, only in compiled assemblies
-
Type Not Found Errors:
- Ensure you're using the correct naming convention
- Check for typos in the class name
-
Testing Best Practices:
- Always reference the source-generated types directly
- Don't create your own domain types class for testing
- Use the same JsonSerializerOptions as the main application
Sekiban supports implementing domain workflows and services that encapsulate business logic that spans multiple aggregates or requires specialized processing.
Domain workflows are stateless services that implement business processes that may involve multiple aggregates or complex validation logic. They are particularly useful for:
- Cross-Aggregate Operations: When a business process spans multiple aggregates
- Complex Validation: When validation requires checking against multiple aggregates or external systems
- Reusable Business Logic: When the same logic is used in multiple places
// Example of a domain workflow for duplicate checking
namespace YourProject.Domain.Workflows;
public static class DuplicateCheckWorkflows
{
// Result type for duplicate check operations
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);
}
// Workflow method that checks for duplicate IDs before registering
public static async Task<DuplicateCheckResult> CheckUserIdDuplicate(
RegisterUserCommand command,
ISekibanExecutor executor)
{
// Check if userId already exists
var userIdExists = await executor.QueryAsync(new UserIdExistsQuery(command.UserId)).UnwrapBox();
if (userIdExists)
{
return DuplicateCheckResult.Duplicate($"User with ID '{command.UserId}' already exists");
}
// If no duplicate, proceed with the command
var result = await executor.CommandAsync(command).UnwrapBox();
return DuplicateCheckResult.Success(result);
}
}
Key Points:
- Workflows are typically implemented as static classes with static methods
- They should be placed in a
Workflows
folder or namespace - They should use
ISekibanExecutor
interface for better testability - They should return domain-specific result types that encapsulate success/failure information
- They can be called from API endpoints or other services
// In Program.cs
apiRoute.MapPost("/users/register",
async ([FromBody] RegisterUserCommand command, [FromServices] SekibanOrleansExecutor executor) =>
{
// Use the workflow to check for duplicates
var result = await DuplicateCheckWorkflows.CheckUserIdDuplicate(command, executor);
if (result.IsDuplicate)
{
return Results.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "Duplicate UserId",
detail: result.ErrorMessage);
}
return result.CommandResult;
});
Workflows can be tested using the same in-memory testing approach as other Sekiban components:
public class DuplicateCheckWorkflowsTests : SekibanInMemoryTestBase
{
protected override SekibanDomainTypes GetDomainTypes() =>
YourDomainDomainTypes.Generate(YourDomainEventsJsonContext.Default.Options);
[Fact]
public async Task CheckUserIdDuplicate_WhenUserIdExists_ReturnsDuplicate()
{
// Arrange - Create a user with the ID we want to test
var existingUserId = "U12345";
var command = new RegisterUserCommand(
"John Doe",
existingUserId,
"john@example.com");
// Register a user with the same ID to ensure it exists
GivenCommand(command);
// Act - Try to register another user with the same 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);
}
}
Key Points:
- Use
SekibanInMemoryTestBase
for testing workflows - The base class provides an
Executor
property that implementsISekibanExecutor
- Use
GivenCommand
to set up the test state - Test both success and failure scenarios
-
Namespace Errors: Make sure to use
Sekiban.Pure.*
namespaces, notSekiban.Core.*
. -
Command Context: The command context doesn't directly expose the aggregate payload. Use pattern matching in your command handlers if you need to check the aggregate state:
if (context.AggregatePayload is YourAggregate aggregate) { // Use aggregate properties }
-
Running the Application: When running the application with the Aspire host, you can use the following command:
dotnet run --project MyProject.AppHost
To launch the AppHost with HTTPS profile, use:
dotnet run --project MyProject.AppHost --launch-profile https
This ensures that your application uses HTTPS for secure communication, which is especially important for production environments.
-
Accessing the Web Frontend: The web frontend is available at the URL shown in the Aspire dashboard, typically at a URL like
https://localhost:XXXXX
. -
ISekibanExecutor vs. SekibanOrleansExecutor: When implementing domain services or workflows, use
ISekibanExecutor
interface instead of the concreteSekibanOrleansExecutor
class for better testability. TheISekibanExecutor
interface is in theSekiban.Pure.Executors
namespace.
Sekiban provides a powerful framework for implementing event sourcing in .NET applications. By understanding the key components and following the best practices outlined in this guide, LLM programming agents can effectively create and maintain Sekiban event sourcing projects.