diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index 3aa18281a..86eac69f3 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -55,7 +55,6 @@ jobs:
src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore/Eventuous.Tests.Extensions.AspNetCore.csproj
src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore.Analyzers/Eventuous.Tests.Extensions.AspNetCore.Analyzers.csproj
src/Gateway/test/Eventuous.Tests.Gateway/Eventuous.Tests.Gateway.csproj
- src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj
src/Experimental/test/Eventuous.Tests.Spyglass.Generators/Eventuous.Tests.Spyglass.Generators.csproj
src/Sqlite/test/Eventuous.Tests.Sqlite/Eventuous.Tests.Sqlite.csproj
src/SignalR/test/Eventuous.Tests.SignalR/Eventuous.Tests.SignalR.csproj
@@ -65,6 +64,11 @@ jobs:
dotnet test "$proj" -c "Debug CI" -f net${{ matrix.dotnet-version }}
echo "::endgroup::"
done
+ -
+ # Uses the sample apps as fixtures, which are pinned to net10.0 for the Aspire AppHost
+ name: Run Spyglass tests
+ if: matrix.dotnet-version == '10.0'
+ run: dotnet test src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj -c "Debug CI" -f net10.0
-
name: Upload Test Results
if: always()
diff --git a/CLAUDE.md b/CLAUDE.md
index 0ba35e6d7..621f0260c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -92,7 +92,7 @@ src/Diagnostics/ OpenTelemetry, Logging
src/Gateway/ Event gateway
src/Testing/ Test utilities
test/ Shared test helpers (Eventuous.Sut.App, Eventuous.Sut.Domain, Eventuous.TestHelpers, Eventuous.TestHelpers.TUnit)
-samples/ Sample apps (esdb, postgres, kurrentdb, banking)
+samples/ Sample apps (kurrentdb with Aspire AppHost, postgres)
```
## External Repos to Update
diff --git a/Directory.Packages.props b/Directory.Packages.props
index e6e4e6d06..c941a2843 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -17,12 +17,18 @@
4.13.0
+
+ 13.4.6
+
10.0.3
1.63.0
+
+
+
diff --git a/Eventuous.slnx b/Eventuous.slnx
index 546b2b5e8..f028ec763 100644
--- a/Eventuous.slnx
+++ b/Eventuous.slnx
@@ -156,6 +156,7 @@
+
diff --git a/samples/kurrentdb/Bookings.AppHost/AppHost.cs b/samples/kurrentdb/Bookings.AppHost/AppHost.cs
new file mode 100644
index 000000000..1c61f1b9a
--- /dev/null
+++ b/samples/kurrentdb/Bookings.AppHost/AppHost.cs
@@ -0,0 +1,63 @@
+using System.Runtime.InteropServices;
+using Scalar.Aspire;
+
+var builder = DistributedApplication.CreateBuilder(args);
+
+// Same image as the repository's KurrentDB test fixtures
+var kurrentImage = RuntimeInformation.ProcessArchitecture == Architecture.Arm64
+ ? "kurrentplatform/kurrentdb:26.1.1-experimental-arm64-10.0-noble"
+ : "kurrentplatform/kurrentdb:26.1.1";
+var imageParts = kurrentImage.Split(':');
+
+var kurrentdb = builder.AddContainer("kurrentdb", imageParts[0], imageParts[1])
+ .WithArgs("--insecure", "--run-projections=All", "--enable-atom-pub-over-http")
+ .WithHttpEndpoint(port: 2113, targetPort: 2113, name: "http");
+
+var kurrentdbEndpoint = kurrentdb.GetEndpoint("http");
+
+var mongoUser = builder.AddParameter("mongo-user", "mongoadmin");
+var mongoPassword = builder.AddParameter("mongo-password", "secret", secret: true);
+
+var mongo = builder.AddMongoDB("mongo", userName: mongoUser, password: mongoPassword)
+ // MongoDB 8.3 refuses to start on Linux kernel 6.19+ (SERVER-121912)
+ .WithImageTag("7.0");
+
+var storage = builder.AddAzureStorage("storage").RunAsEmulator();
+var blobs = storage.AddBlobs("blobs");
+storage.AddBlobContainer("bookings-container", blobContainerName: "bookings");
+
+var bookings = builder.AddProject("bookings")
+ .WithHttpEndpoint()
+ .WithHttpHealthCheck("/health")
+ .WithReference(blobs)
+ .WithEnvironment(ctx => {
+ ctx.EnvironmentVariables["KurrentDB__ConnectionString"] = ReferenceExpression.Create(
+ $"kurrentdb://{kurrentdbEndpoint.Property(EndpointProperty.Host)}:{kurrentdbEndpoint.Property(EndpointProperty.Port)}?tls=false"
+ );
+ ctx.EnvironmentVariables["Mongo__ConnectionString"] = mongo.Resource.ConnectionStringExpression;
+ }
+ )
+ .WaitFor(kurrentdb)
+ .WaitFor(mongo)
+ .WaitFor(blobs);
+
+var payments = builder.AddProject("payments")
+ .WithHttpEndpoint()
+ .WithHttpHealthCheck("/health")
+ .WithEnvironment(ctx => {
+ ctx.EnvironmentVariables["KurrentDB__ConnectionString"] = ReferenceExpression.Create(
+ $"kurrentdb://{kurrentdbEndpoint.Property(EndpointProperty.Host)}:{kurrentdbEndpoint.Property(EndpointProperty.Port)}?tls=false"
+ );
+ ctx.EnvironmentVariables["Mongo__ConnectionString"] = mongo.Resource.ConnectionStringExpression;
+ }
+ )
+ .WaitFor(kurrentdb)
+ .WaitFor(mongo);
+
+builder.AddScalarApiReference()
+ .WithApiReference(bookings)
+ .WithApiReference(payments)
+ .WaitFor(bookings)
+ .WaitFor(payments);
+
+builder.Build().Run();
diff --git a/samples/kurrentdb/Bookings.AppHost/Bookings.AppHost.csproj b/samples/kurrentdb/Bookings.AppHost/Bookings.AppHost.csproj
new file mode 100644
index 000000000..f8c3433e9
--- /dev/null
+++ b/samples/kurrentdb/Bookings.AppHost/Bookings.AppHost.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ 830ae8e6-48ba-4d84-b460-a2922dc3ec63
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/kurrentdb/Bookings.Payments/Application/CommandApi.cs b/samples/kurrentdb/Bookings.Payments/Application/CommandApi.cs
index ac66176c0..44b231fc2 100644
--- a/samples/kurrentdb/Bookings.Payments/Application/CommandApi.cs
+++ b/samples/kurrentdb/Bookings.Payments/Application/CommandApi.cs
@@ -4,7 +4,7 @@
using Microsoft.AspNetCore.Mvc;
using static Bookings.Payments.Application.PaymentCommands;
-namespace Bookings.Payments.Application;
+namespace Bookings.Payments.Application;
[Route("payment")]
public class CommandApi(ICommandService service) : CommandHttpApiBase(service) {
diff --git a/samples/kurrentdb/Bookings.Payments/Application/CommandService.cs b/samples/kurrentdb/Bookings.Payments/Application/CommandService.cs
index 4e1c94066..a92dadbc6 100644
--- a/samples/kurrentdb/Bookings.Payments/Application/CommandService.cs
+++ b/samples/kurrentdb/Bookings.Payments/Application/CommandService.cs
@@ -14,9 +14,8 @@ public CommandService(IEventStore store) : base(store) {
}
}
-// [AggregateCommands(typeof(Payment))]
public static class PaymentCommands {
- [HttpCommand]
+ [HttpCommand]
public record RecordPayment(
string PaymentId,
string BookingId,
diff --git a/samples/kurrentdb/Bookings.Payments/Bookings.Payments.csproj b/samples/kurrentdb/Bookings.Payments/Bookings.Payments.csproj
index ff30478df..131bc5f68 100644
--- a/samples/kurrentdb/Bookings.Payments/Bookings.Payments.csproj
+++ b/samples/kurrentdb/Bookings.Payments/Bookings.Payments.csproj
@@ -24,12 +24,14 @@
+
+
diff --git a/samples/kurrentdb/Bookings.Payments/Program.cs b/samples/kurrentdb/Bookings.Payments/Program.cs
index fd9fe2272..8ca0222ee 100644
--- a/samples/kurrentdb/Bookings.Payments/Program.cs
+++ b/samples/kurrentdb/Bookings.Payments/Program.cs
@@ -10,6 +10,7 @@
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
+builder.Services.AddHealthChecks();
// OpenTelemetry instrumentation must be added before adding Eventuous services
builder.Services.AddTelemetry();
@@ -20,14 +21,16 @@
var app = builder.Build();
app.Services.AddEventuousLogs();
-app.UseSwagger();
+// Serve the OpenAPI document where the Scalar API reference in the Aspire AppHost expects it
+app.UseSwagger(c => c.RouteTemplate = "openapi/{documentName}.json");
app.UseOpenTelemetryPrometheusScrapingEndpoint();
// Here we discover commands by their annotations
app.MapDiscoveredCommands();
-app.UseSwaggerUI();
+app.UseSwaggerUI(c => c.SwaggerEndpoint("/openapi/v1.json", "Payments v1"));
app.MapEventuousSpyglass();
+app.MapHealthChecks("/health");
app.Run();
\ No newline at end of file
diff --git a/samples/kurrentdb/Bookings.Payments/Registrations.cs b/samples/kurrentdb/Bookings.Payments/Registrations.cs
index d1d599bdd..85f1db76f 100644
--- a/samples/kurrentdb/Bookings.Payments/Registrations.cs
+++ b/samples/kurrentdb/Bookings.Payments/Registrations.cs
@@ -1,7 +1,9 @@
+using System.Text.Json;
using Bookings.Payments.Application;
using Bookings.Payments.Domain;
using Bookings.Payments.Infrastructure;
using Bookings.Payments.Integration;
+using Eventuous;
using Eventuous.Diagnostics.OpenTelemetry;
using Eventuous.KurrentDB;
using Eventuous.KurrentDB.Producers;
@@ -16,6 +18,8 @@ namespace Bookings.Payments;
public static class Registrations {
extension(IServiceCollection services) {
public void AddServices(IConfiguration configuration) {
+ EventSerializer.SetDefault(new DefaultEventSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)));
+
services.AddKurrentDBClient(configuration["KurrentDB:ConnectionString"]!);
services.AddEventStore();
services.AddCommandService();
@@ -31,24 +35,36 @@ public void AddServices(IConfiguration configuration) {
}
public void AddTelemetry() {
+ var otelEnabled = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT") != null;
+
services.AddOpenTelemetry()
.WithMetrics(
- builder => builder
- .AddAspNetCoreInstrumentation()
- .AddEventuous()
- .AddEventuousSubscriptions()
- .AddPrometheusExporter()
+ builder => {
+ builder
+ .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("payments"))
+ .AddAspNetCoreInstrumentation()
+ .AddEventuous()
+ .AddEventuousSubscriptions()
+ .AddPrometheusExporter();
+ if (otelEnabled) builder.AddOtlpExporter();
+ }
);
services.AddOpenTelemetry()
.WithTracing(
- builder => builder
- .AddAspNetCoreInstrumentation()
- .AddGrpcClientInstrumentation()
- .AddEventuousTracing()
- .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("payments"))
- .SetSampler(new AlwaysOnSampler())
- .AddZipkinExporter()
+ builder => {
+ builder
+ .AddAspNetCoreInstrumentation()
+ .AddGrpcClientInstrumentation()
+ .AddEventuousTracing()
+ .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("payments"))
+ .SetSampler(new AlwaysOnSampler());
+
+ if (otelEnabled)
+ builder.AddOtlpExporter();
+ else
+ builder.AddZipkinExporter();
+ }
);
}
}
diff --git a/samples/kurrentdb/Bookings/Application/BookingsQueryService.cs b/samples/kurrentdb/Bookings/Application/BookingsQueryService.cs
index f3c86be71..d35fe874b 100644
--- a/samples/kurrentdb/Bookings/Application/BookingsQueryService.cs
+++ b/samples/kurrentdb/Bookings/Application/BookingsQueryService.cs
@@ -1,9 +1,30 @@
+using Azure;
+using Azure.Storage.Blobs;
using Bookings.Application.Queries;
+using Eventuous.Azure.Storage.Blobs;
using Eventuous.Projections.MongoDB.Tools;
using MongoDB.Driver;
namespace Bookings.Application;
-public class BookingsQueryService(IMongoDatabase database) {
+public class BookingsQueryService(IMongoDatabase database, BlobServiceClient blobClient, BlobStorageProjectorOptions blobOptions) {
public async Task GetUserBookings(string userId) => await database.LoadDocument(userId);
+
+ ///
+ /// Reads the booking state projected to Azure Blob Storage. The blob name follows the
+ /// projector's default naming convention: {id}/{state type name}.json.
+ ///
+ public async Task GetBooking(string bookingId, CancellationToken cancellationToken) {
+ var blob = blobClient
+ .GetBlobContainerClient(BookingStateBlobProjection.ContainerName)
+ .GetBlobClient($"{bookingId}/{nameof(BookingView)}.json");
+
+ try {
+ var content = await blob.DownloadContentAsync(cancellationToken);
+
+ return content.Value.Content.ToObjectFromJson(blobOptions.JsonOptions);
+ } catch (RequestFailedException e) when (e.Status == 404) {
+ return null;
+ }
+ }
}
diff --git a/samples/kurrentdb/Bookings/Application/Queries/BookingStateBlobProjection.cs b/samples/kurrentdb/Bookings/Application/Queries/BookingStateBlobProjection.cs
new file mode 100644
index 000000000..5b431d97d
--- /dev/null
+++ b/samples/kurrentdb/Bookings/Application/Queries/BookingStateBlobProjection.cs
@@ -0,0 +1,34 @@
+using Azure.Storage.Blobs;
+using Eventuous.Azure.Storage.Blobs;
+using static Bookings.Domain.Bookings.BookingEvents;
+
+namespace Bookings.Application.Queries;
+
+///
+/// Projects the booking state to Azure Blob Storage, in parallel with the MongoDB projections.
+/// Each booking stream becomes one JSON blob. It runs on its own all-stream subscription with
+/// its own checkpoint, so it can replay from the beginning of the stream and backfill the blobs
+/// when added to an existing system. The all-stream subscription provides real global positions,
+/// so the projector can use ByGlobalPosition idempotency to skip replayed events.
+///
+public class BookingStateBlobProjection : BlobStorageProjector {
+ public const string ContainerName = "bookings";
+
+ public BookingStateBlobProjection(BlobServiceClient client, BlobStorageProjectorOptions options)
+ : base(client, ContainerName, options) {
+ On((ctx, view) => view with {
+ Id = ctx.Stream.GetId(),
+ GuestId = ctx.Message.GuestId,
+ RoomId = ctx.Message.RoomId,
+ CheckInDate = ctx.Message.CheckInDate,
+ CheckOutDate = ctx.Message.CheckOutDate,
+ BookingPrice = ctx.Message.BookingPrice,
+ Outstanding = ctx.Message.OutstandingAmount
+ }
+ );
+
+ On((view, evt) => view with { Outstanding = evt.Outstanding });
+
+ On((view, _) => view with { Paid = true });
+ }
+}
diff --git a/samples/kurrentdb/Bookings/Application/Queries/BookingView.cs b/samples/kurrentdb/Bookings/Application/Queries/BookingView.cs
new file mode 100644
index 000000000..68f9ad591
--- /dev/null
+++ b/samples/kurrentdb/Bookings/Application/Queries/BookingView.cs
@@ -0,0 +1,19 @@
+using NodaTime;
+
+// ReSharper disable UnusedAutoPropertyAccessor.Global
+namespace Bookings.Application.Queries;
+
+///
+/// Booking state projected to Azure Blob Storage, one blob per booking stream.
+/// Requires a parameterless constructor, as the blob projector creates a new instance for new blobs.
+///
+public record BookingView {
+ public string Id { get; init; } = "";
+ public string GuestId { get; init; } = "";
+ public string RoomId { get; init; } = "";
+ public LocalDate CheckInDate { get; init; }
+ public LocalDate CheckOutDate { get; init; }
+ public float BookingPrice { get; init; }
+ public float Outstanding { get; init; }
+ public bool Paid { get; init; }
+}
diff --git a/samples/kurrentdb/Bookings/Bookings.csproj b/samples/kurrentdb/Bookings/Bookings.csproj
index 2d46cb2c6..f26277599 100644
--- a/samples/kurrentdb/Bookings/Bookings.csproj
+++ b/samples/kurrentdb/Bookings/Bookings.csproj
@@ -24,6 +24,7 @@
+
diff --git a/samples/kurrentdb/Bookings/Program.cs b/samples/kurrentdb/Bookings/Program.cs
index 0060b1549..e78f6706f 100644
--- a/samples/kurrentdb/Bookings/Program.cs
+++ b/samples/kurrentdb/Bookings/Program.cs
@@ -1,6 +1,8 @@
using System.Text.Json.Serialization;
+using Azure.Storage.Blobs;
using Bookings;
using Bookings.Application;
+using Bookings.Application.Queries;
using Bookings.Domain.Bookings;
using Eventuous;
using Eventuous.Diagnostics.Logging;
@@ -29,6 +31,7 @@
builder.Services.AddControllers().AddJsonOptions(cfg => cfg.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
+builder.Services.AddHealthChecks();
builder.Services.AddTelemetry();
builder.Services.AddEventuous(builder.Configuration);
builder.Services.Configure(options => options.SerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb));
@@ -36,10 +39,13 @@
var app = builder.Build();
app.UseSerilogRequestLogging();
-app.UseSwagger().UseSwaggerUI();
+// Serve the OpenAPI document where the Scalar API reference in the Aspire AppHost expects it
+app.UseSwagger(c => c.RouteTemplate = "openapi/{documentName}.json");
+app.UseSwaggerUI(c => c.SwaggerEndpoint("/openapi/v1.json", "Bookings v1"));
app.MapControllers();
app.UseOpenTelemetryPrometheusScrapingEndpoint();
app.MapEventuousSpyglass();
+app.MapHealthChecks("/health");
app.MapGet(
"/bookings/my/{userId}",
@@ -50,11 +56,30 @@
}
);
+// Unlike GET /bookings/{id}, which folds the state from the event stream, this endpoint
+// serves the read model projected to Azure Blob Storage
+app.MapGet(
+ "/bookings/{bookingId}/view",
+ async (string bookingId, BookingsQueryService queryService, CancellationToken cancellationToken) => {
+ var booking = await queryService.GetBooking(bookingId, cancellationToken);
+
+ return booking == null ? Results.NotFound() : Results.Ok(booking);
+ }
+);
+
+// The blob projector doesn't create the container, and outside Aspire nothing else does
+await app.Services.GetRequiredService()
+ .GetBlobContainerClient(BookingStateBlobProjection.ContainerName)
+ .CreateIfNotExistsAsync();
+
var factory = app.Services.GetRequiredService();
var listener = new LoggingEventListener(factory, "OpenTelemetry");
+// The Aspire AppHost assigns URLs via ASPNETCORE_URLS; keep the fixed port for standalone runs
+if (Environment.GetEnvironmentVariable("ASPNETCORE_URLS") == null) app.Urls.Add("http://*:5051");
+
try {
- app.Run("http://*:5051");
+ app.Run();
return 0;
} catch (Exception e) {
diff --git a/samples/kurrentdb/Bookings/Registrations.cs b/samples/kurrentdb/Bookings/Registrations.cs
index 66e58b300..50e960d2d 100644
--- a/samples/kurrentdb/Bookings/Registrations.cs
+++ b/samples/kurrentdb/Bookings/Registrations.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using Azure.Storage.Blobs;
using Bookings.Application;
using Bookings.Application.Queries;
using Bookings.Domain;
@@ -6,6 +7,7 @@
using Bookings.Infrastructure;
using Bookings.Integration;
using Eventuous;
+using Eventuous.Azure.Storage.Blobs;
using Eventuous.Diagnostics.OpenTelemetry;
using Eventuous.KurrentDB;
using Eventuous.KurrentDB.Subscriptions;
@@ -23,9 +25,8 @@ namespace Bookings;
public static class Registrations {
extension(IServiceCollection services) {
public void AddEventuous(IConfiguration configuration) {
- EventSerializer.SetDefault(
- new DefaultEventSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web).ConfigureForNodaTime(DateTimeZoneProviders.Tzdb))
- );
+ var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web).ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
+ EventSerializer.SetDefault(new DefaultEventSerializer(jsonOptions));
services.AddKurrentDBClient(configuration["KurrentDB:ConnectionString"]!);
services.AddEventStore();
@@ -36,6 +37,15 @@ public void AddEventuous(IConfiguration configuration) {
services.AddSingleton(Mongo.ConfigureMongo(configuration));
+ services.AddSingleton(new BlobServiceClient(configuration.GetConnectionString("blobs")));
+ services.AddSingleton(
+ new BlobStorageProjectorOptions {
+ JsonOptions = jsonOptions,
+ RaceRetries = 3,
+ IdempotencyMode = IdempotencyMode.ByGlobalPosition
+ }
+ );
+
services.AddSubscription(
"BookingsProjections",
builder => builder
@@ -44,6 +54,16 @@ public void AddEventuous(IConfiguration configuration) {
.AddEventHandler()
.WithPartitioningByStream(2)
);
+
+ // The blob projection runs on its own subscription with its own checkpoint, so when
+ // it's added to a system with existing data, it replays all events from the beginning
+ // and backfills the blobs instead of starting from the other projections' position
+ services.AddSubscription(
+ "BookingsBlobProjection",
+ builder => builder
+ .UseCheckpointStore()
+ .AddEventHandler()
+ );
services.AddSingleton();
services.AddSubscription(
diff --git a/samples/kurrentdb/Bookings/appsettings.json b/samples/kurrentdb/Bookings/appsettings.json
index 70f8e4352..9d8634280 100644
--- a/samples/kurrentdb/Bookings/appsettings.json
+++ b/samples/kurrentdb/Bookings/appsettings.json
@@ -1,4 +1,7 @@
{
+ "ConnectionStrings": {
+ "blobs": "UseDevelopmentStorage=true"
+ },
"Mongo": {
"ConnectionString": "mongodb://localhost:27017",
"User": "mongoadmin",
diff --git a/samples/kurrentdb/Directory.Build.props b/samples/kurrentdb/Directory.Build.props
new file mode 100644
index 000000000..b128b3648
--- /dev/null
+++ b/samples/kurrentdb/Directory.Build.props
@@ -0,0 +1,8 @@
+
+
+
+
+ net10.0
+ net10.0
+
+
diff --git a/samples/kurrentdb/README.md b/samples/kurrentdb/README.md
new file mode 100644
index 000000000..b0d9562f5
--- /dev/null
+++ b/samples/kurrentdb/README.md
@@ -0,0 +1,51 @@
+# Bookings sample (KurrentDB)
+
+A two-service hotel booking application demonstrating Eventuous with KurrentDB as the event store:
+
+- **Bookings** — commands and queries for room bookings. Projects booking state to **MongoDB**
+ (`BookingStateProjection`, `MyBookingsProjection`) and to **Azure Blob Storage**
+ (`BookingStateBlobProjection`), showing multiple projection targets side by side. The blob
+ projection uses `ByGlobalPosition` idempotency and race retries, and runs on its own
+ subscription with its own checkpoint — so when it's added to a system with existing data,
+ it replays the stream from the beginning and backfills the blobs.
+- **Bookings.Payments** — records payments and publishes integration events back to KurrentDB
+ through the Eventuous gateway; the Bookings service consumes them with a persistent subscription.
+
+## Run with .NET Aspire
+
+The `Bookings.AppHost` project orchestrates everything: KurrentDB, MongoDB, the Azurite blob
+storage emulator, both services, and a Scalar API reference for browsing the APIs. Telemetry from
+both services flows to the Aspire dashboard via OTLP.
+
+```bash
+aspire run
+# or
+dotnet run --project Bookings.AppHost
+```
+
+Requires the [Aspire CLI](https://learn.microsoft.com/dotnet/aspire/cli/install) (for `aspire run`)
+and a container runtime such as Docker Desktop.
+
+## Run standalone
+
+Start the infrastructure, then run the services:
+
+```bash
+docker compose up -d
+dotnet run --project Bookings # listens on :5051
+dotnet run --project Bookings.Payments
+```
+
+On Apple Silicon, edit `docker-compose.yml` to use the arm64 KurrentDB image (see the comment there).
+The compose file also starts Zipkin, Prometheus, Grafana, and Seq for the observability tooling the
+services use when no OTLP endpoint is configured.
+
+## Try it
+
+1. Book a room: `POST /booking/book` on the Bookings service.
+2. Record a payment on the Payments service — the command endpoints are discovered from
+ annotations; find them in the service's API reference (Scalar in Aspire, Swagger UI standalone).
+3. Read the Mongo projection: `GET /bookings/my/{userId}`.
+4. Read the blob projection: `GET /bookings/{bookingId}/view` — served from the `bookings` blob
+ container, one JSON blob per booking (while `GET /bookings/{bookingId}` folds the state from
+ the event stream).
diff --git a/samples/kurrentdb/aspire.config.json b/samples/kurrentdb/aspire.config.json
new file mode 100644
index 000000000..cbce11337
--- /dev/null
+++ b/samples/kurrentdb/aspire.config.json
@@ -0,0 +1,5 @@
+{
+ "appHost": {
+ "path": "Bookings.AppHost/Bookings.AppHost.csproj"
+ }
+}
diff --git a/samples/kurrentdb/docker-compose.yml b/samples/kurrentdb/docker-compose.yml
index 4154d40c9..bf2285314 100644
--- a/samples/kurrentdb/docker-compose.yml
+++ b/samples/kurrentdb/docker-compose.yml
@@ -1,21 +1,19 @@
services:
-# esdb:
-# container_name: esdemo-esdb
-# image: eventstore/eventstore:23.10.2-alpha-arm64v8
-# # image: eventstore/eventstore:latest #23.10.2-buster-slim
-# ports:
-# - '2113:2113'
-# - '1113:1113'
-# environment:
-# EVENTSTORE_INSECURE: 'true'
-# EVENTSTORE_CLUSTER_SIZE: 1
-# EVENTSTORE_EXT_TCP_PORT: 1113
-# EVENTSTORE_HTTP_PORT: 2113
-# EVENTSTORE_ENABLE_EXTERNAL_TCP: 'true'
-# EVENTSTORE_RUN_PROJECTIONS: all
-# EVENTSTORE_START_STANDARD_PROJECTIONS: "true"
-# EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP: "true"
+ kurrentdb:
+ container_name: esdemo-kurrentdb
+ # On Apple Silicon, use kurrentplatform/kurrentdb:26.1.1-experimental-arm64-10.0-noble
+ image: kurrentplatform/kurrentdb:26.1.1
+ command: --insecure --run-projections=All --enable-atom-pub-over-http
+ ports:
+ - '2113:2113'
+
+ azurite:
+ container_name: esdemo-azurite
+ image: mcr.microsoft.com/azure-storage/azurite
+ command: azurite-blob --blobHost 0.0.0.0 --skipApiVersionCheck
+ ports:
+ - '10000:10000'
mongo:
container_name: esdemo-mongo
diff --git a/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj b/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj
index 4801b4d5e..7fe734f15 100644
--- a/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj
+++ b/src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj
@@ -2,6 +2,8 @@
Exe
true
+
+ net10.0