In the previous article, we created a C# application using Sekiban DCB to handle students, classes, and class enrollment. This time, we will try running that application with SekibanWasmRuntime, which allows Sekiban to run with Wasm (WebAssembly).
In this article, we will run a project written in C# as Wasm. SekibanWasmRuntime currently provides primary support for C# and Rust, while Go, TypeScript, MoonBit, and Swift are also available as experimental implementations.
The sample code used in this article is available here.
1. Roles of Sekiban and Wasm
Sekiban is an event sourcing framework that provides a mechanism for storing events that occur in an application and building the current state from those events.
Wasm is an executable format produced by compiling code. In this example, SekibanWasmRuntime running on the server loads and executes the Wasm file. The application contains the business code related to students, while the Runtime provides the environment for executing that code and the mechanism for storing events.
2. What Runs in Wasm
The architecture used in this example is as follows.
Scalar / PowerShell
↓
API
↓ ISekibanExecutor
RemoteSekibanExecutor
├── Executes existing Command handlers on the API side
└── Requests state retrieval, event commit, and Query execution over HTTP
↓
SekibanWasmRuntime
├── Executes the Projector and list Query in Wasm
└── Stores events in PostgreSQL
Command handlers run on the API side, while the Projector that builds state from events and the student list processing run as Wasm inside the Runtime.
When a student is created, the process works in the following order.
- The HTTP endpoint receives the request to create a student.
- The existing Command handler runs on the API side and creates an event according to the business rules.
- The API requests the Runtime to store the event through the Remote connection.
- The Runtime stores the event, and the Projector in Wasm applies the event when state is retrieved or the list is updated.
- The retrieval API can then be used to check the state of the created student or the student list.
The business logic remains unchanged, but we need to add an entry point for calling Wasm, type registration, and API connection settings. The entire Web API and all Commands do not run inside Wasm.
For this example, I used the following versions.
| Component | Version |
|---|---|
| .NET | 10 |
| Sekiban.Dcb | 10.19.0 |
| Sekiban.Dcb.WasmRuntime.Aspire | 1.0.0-preview.6 |
| Sekiban.Dcb.WasmRuntime.Remote | 1.0.0-preview.6 |
| Runtime container | 1.0.0-preview.3 |
The NuGet packages and the Runtime container use different version series.
3. What Was Added or Changed
To reuse the existing business logic, I added the Wasm entry point and execution environment.
The official C# Wasm template specifies using Docker when building Wasm on Windows or macOS. Following this procedure, we prepare the build environment for compiling C# to Wasm inside Docker. The generated Wasm file is then loaded and executed by SekibanWasmRuntime.
| Added or changed item | Purpose |
|---|---|
| Wasm project | References the existing EventSource and builds it as Wasm |
| Wasm entry point and type registration | Connects the Runtime to the existing Projector and provides the type information required for ahead-of-time compilation |
| manifest | Tells the Runtime which Wasm, events, Projectors, and Queries are associated with each other |
| Build scripts | Build Wasm inside Docker |
| AppHost settings | Start the Runtime container and a dedicated PostgreSQL instance |
| Remote connection for the API | Replaces the ISekibanExecutor implementation with RemoteSekibanExecutor
|
The Wasm project references the existing code as follows. The following code snippets show the main settings and implementations that were added.
<ProjectReference Include="..\DCBNativeWasmProject.EventSource\DCBNativeWasmProject.EventSource.csproj" />
At the entry point called by the Runtime, we use the existing Projector.
instance.State = StudentProjector.Project(instance.State, ev);
On the API side, we register the implementation used to communicate with the Runtime.
builder.Services.AddHttpClient<RemoteSekibanExecutor>(http =>
{
http.BaseAddress = new Uri(builder.Configuration["Runtime:BaseUrl"]!);
http.Timeout = TimeSpan.FromSeconds(90);
});
builder.Services.AddTransient<ISekibanExecutor>(
sp => sp.GetRequiredService<RemoteSekibanExecutor>());
With this configuration, the existing endpoints can continue calling executor.ExecuteAsync(command).
Package and Wasm Configuration
Run the following commands from the project root directory. Prepare the .NET 10 SDK and Docker Desktop beforehand. On Windows, use Git Bash, and on macOS, use a terminal. Because the build tools are provided inside Docker, you do not need to install the WASI SDK directly.
The packages added to AppHost and the API are as follows.
dotnet add ./DCBNativeWasmProject.AppHost\DCBNativeWasmProject.AppHost.csproj package Sekiban.Dcb.WasmRuntime.Aspire --version 1.0.0-preview.6
dotnet add ./DCBNativeWasmProject.ApiService\DCBNativeWasmProject.ApiService.csproj package Sekiban.Dcb.WasmRuntime.Remote --version 1.0.0-preview.6
The following settings are added to DCBNativeWasmProject.Wasm/DCBNativeWasmProject.Wasm.csproj to generate Wasm output. These are the main settings.
<TargetFramework>net10.0</TargetFramework>
<RuntimeIdentifier>wasi-wasm</RuntimeIdentifier>
<OutputType>library</OutputType>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<InvariantGlobalization>true</InvariantGlobalization>
<IsAotCompatible>true</IsAotCompatible>
<SelfContained>true</SelfContained>
<IlcExportUnmanagedEntrypoints>true</IlcExportUnmanagedEntrypoints>
<NativeLib>Shared</NativeLib>
The actual file also includes a package reference for NativeAOT-LLVM and linker settings that expose the functions called by the Runtime. In WasmExports.cs, type information is explicitly registered, and entry points are provided for applying events, saving and restoring state, and retrieving lists. NuGet.wasm.config specifies the package sources required for this build.
Map Wasm and Processing in the manifest
config/sekiban-manifest.json tells the Runtime which Projectors and Queries are provided by each Wasm module. The following is an excerpt related to the student list.
{
"defaultModulePath": "/app/modules/dcb-native.wasm",
"queryAssemblyVersion": "wasm",
"projectors": [
{
"projectorName": "StudentListProjection",
"projectorVersion": "1.0.1",
"modulePath": "/app/modules/dcb-native.wasm",
"abiKind": "wasi-preview1",
"moduleVersion": "1.0.0"
}
],
"queryProjectors": {
"GetStudentListQuery": "StudentListProjection"
}
}
Connect the Runtime and API from AppHost
In DCBNativeWasmProject.AppHost/WasmAppHost.cs, we start the Runtime and a PostgreSQL instance. root is the absolute path to the project root.
var pg = builder.AddPostgres("wasm-postgres");
var events = pg.AddDatabase("SekibanDcb", "dcb_native_wasm_events");
var runtime = builder.AddSekibanWasmRuntime(
"wasm-runtime",
new SekibanWasmRuntimeOptions
{
ConfigDirectory = Path.Combine(root, "config"),
ModulesDirectory = Path.Combine(root, "modules"),
WasmModulePath = "/app/modules/dcb-native.wasm",
EventStoreDatabase = events,
ProjectionMode = "memory-only",
HostPort = 5280
})
.WithEnvironment("SEKIBAN_WASM_POOL_SIZE", "0")
.WaitFor(events);
memory-only is the projection mode used to build projections by applying events. The events themselves are stored in PostgreSQL. SEKIBAN_WASM_POOL_SIZE=0 is set as a workaround for an issue in this preview version where consecutive list updates could wait indefinitely. This is not a recommended value that has been evaluated for production use.
Next, we pass the Runtime URL to the API.
builder.AddProject<DCBNativeWasmProject_ApiService>(
"wasm-api", launchProfileName: null)
.WithEnvironment("Runtime__BaseUrl", runtime.GetEndpoint("http"))
.WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development")
.WithHttpEndpoint(
port: 5241, targetPort: 5241, name: "http", isProxied: false)
.WithEnvironment("ASPNETCORE_URLS", "http://localhost:5241")
.WaitFor(runtime);
Runtime__BaseUrl passes the SekibanWasmRuntime endpoint to the API. The API uses this endpoint to request state retrieval and event storage from the Runtime.
4. Build the Wasm File
Start Docker Desktop and move to the project root directory in your terminal. Adjust the path to match your working directory.
bash scripts/build-wasm.sh
The script uses scripts/Dockerfile.wasm-build to create a Docker image for the build, and then scripts/build-wasm-container.sh runs the following command inside the container.
dotnet publish DCBNativeWasmProject.Wasm/DCBNativeWasmProject.Wasm.csproj \
-c Release -r wasi-wasm \
-o artifacts/wasm/publish --configfile NuGet.wasm.config
The build result is copied to the location used by the Runtime, and wasm-tools validate is used to validate the Wasm format. When the build succeeds, a log similar to the following is displayed. The file size may change depending on the implementation.
Example output when the Wasm build completes:
[build-wasm] OK: modules/dcb-native.wasm (25282599 bytes)
Output Locations of the Build Files
The following paths are relative to the project root.
| Location | Description |
|---|---|
artifacts/wasm/publish/DCBNativeWasmProject.Wasm.wasm |
Wasm generated by publish
|
modules/dcb-native.wasm |
A copy of the generated Wasm file that is loaded by the Runtime |
DCBNativeWasmProject.Wasm/bin/ and obj/
|
Build outputs and intermediate files |
config/sekiban-manifest.json |
Runtime configuration created manually. This is not a build output. |
The modules folder on the host is mounted into the Runtime container. Therefore, the manifest and AppHost specify /app/modules/dcb-native.wasm, which is the path inside the container.
The Wasm project used in this example is built separately from the normal solution build. Run the dedicated build script before starting AppHost.
5. Start the Application
In the same terminal, set the environment variables and start AppHost.
export ASPIRE_ALLOW_UNSECURED_TRANSPORT=true
export ASPNETCORE_URLS=http://localhost:15900
export ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL=http://localhost:15901
export ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL=http://localhost:15902
dotnet run --project DCBNativeWasmProject.AppHost --no-launch-profile
These settings are for local execution. AppHost starts the dedicated PostgreSQL instance, the Runtime container, and the API.
6. Execution Results
Create a student from Scalar, and then retrieve the student using the same ID. The following is the actual response returned by the API for the request shown below. The IDs will be different each time you run it.
POST /api/students/
Content-Type: application/json
{
"studentId": "10c8f945-8314-4631-8c3d-627dbd22934a",
"name": "山田太郎",
"maxClassCount": 5
}
{
"studentId": "10c8f945-8314-4631-8c3d-627dbd22934a",
"eventId": "01a08f5c-d51e-7cf9-9e22-ad1348d4137e",
"sortableUniqueId": "063924708420894567502099121181",
"message": "Student created successfully"
}
Next, retrieve the student that was created.
GET /api/students/10c8f945-8314-4631-8c3d-627dbd22934a
{
"studentId": "10c8f945-8314-4631-8c3d-627dbd22934a",
"payload": {
"studentId": "10c8f945-8314-4631-8c3d-627dbd22934a",
"name": "山田太郎",
"maxClassCount": 5,
"enrolledClassRoomIds": []
},
"version": 1
}
The student creation event is applied by the Projector running in Wasm, and we can retrieve the student's state. List retrieval and updates can also be executed from the same Scalar screen.
7. Conclusion
By using SekibanWasmRuntime, we can change the execution environment while continuing to use existing models and Projectors, as in this example, by adding a Wasm entry point and connection settings. The business logic we have already created can also be used with this architecture.
I also felt that by leaving event storage and Wasm execution to SekibanWasmRuntime, the application side can focus more on implementing the business logic: what kinds of operations the application accepts and what kinds of events those operations produce. Because the API can use the Runtime through a Remote connection, the part that calls the business processing can be separated from the part responsible for execution and storage.
Samples for Go and TypeScript are also available as experimental/reference implementations, and I am looking forward to seeing further support added in the future.
Top comments (0)