dotnet-cqrs/Svrnty.Sample/Program.cs

517 lines
21 KiB
C#

using Microsoft.AspNetCore.Server.Kestrel.Core;
using Svrnty.CQRS.Events.Abstractions.Subscriptions;
using Svrnty.Sample.Commands;
using Svrnty.Sample.Workflows;
using Svrnty.Sample.Events;
using Svrnty.CQRS.Events.Abstractions.Schema;
using Svrnty.CQRS.Events.Abstractions.Models;
using Svrnty.CQRS;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.Grpc;
using Svrnty.Sample;
using Svrnty.Sample.Projections;
using Svrnty.Sample.Sagas;
// using Svrnty.Sample.Invitations; // Phase 8 - temporarily disabled
using Svrnty.CQRS.MinimalApi;
using Svrnty.CQRS.DynamicQuery;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Events;
using Svrnty.CQRS.Events.Abstractions;
using Svrnty.CQRS.Events.Abstractions.Sagas;
using Svrnty.CQRS.Events.Grpc;
using Svrnty.CQRS.Events.PostgreSQL;
using Svrnty.CQRS.Events.PostgreSQL.Subscriptions;
using Svrnty.CQRS.Events.RabbitMQ;
using Svrnty.CQRS.Events.Projections;
using Svrnty.CQRS.Events.Sagas;
using Svrnty.CQRS.Events.Subscriptions;
using Svrnty.Sample.Queries;
using Svrnty.Sample.Services;
using Svrnty.CQRS.Events.Abstractions.Streaming;
using Svrnty.CQRS.Events.Abstractions.Delivery;
using Svrnty.Sample.BackgroundServices;
// using Svrnty.CQRS.Events.SignalR; // Phase 8 - temporarily disabled
var builder = WebApplication.CreateBuilder(args);
// Configure Kestrel to support both HTTP/1.1 (for REST APIs) and HTTP/2 (for gRPC)
builder.WebHost.ConfigureKestrel(options =>
{
// Port 6000: HTTP/2 for gRPC
options.ListenLocalhost(6000, o => o.Protocols = HttpProtocols.Http2);
// Port 6001: HTTP/1.1 for HTTP API
options.ListenLocalhost(6001, o => o.Protocols = HttpProtocols.Http1);
});
// IMPORTANT: Register dynamic query dependencies FIRST
// (before AddSvrntyCqrs, so gRPC services can find the handlers)
builder.Services.AddTransient<PoweredSoft.Data.Core.IAsyncQueryableService, SimpleAsyncQueryableService>();
builder.Services.AddTransient<PoweredSoft.DynamicQuery.Core.IQueryHandlerAsync, PoweredSoft.DynamicQuery.QueryHandlerAsync>();
builder.Services.AddDynamicQueryWithProvider<User, UserQueryableProvider>();
// Add event streaming support
builder.Services.AddSvrntyEvents();
builder.Services.AddDefaultEventDiscovery();
// Phase 5: Add schema evolution support for event versioning
builder.Services.AddSchemaEvolution();
builder.Services.AddJsonSchemaGeneration();
// Configure event storage (PostgreSQL or in-memory)
var usePostgreSQL = builder.Configuration.GetValue<bool>("EventStreaming:UsePostgreSQL");
if (usePostgreSQL)
{
builder.Services.AddPostgresEventStreaming(
builder.Configuration.GetSection("EventStreaming:PostgreSQL"));
builder.Services.AddPostgresSchemaStore(); // Use PostgreSQL for schema storage
}
else
{
builder.Services.AddInMemoryEventStorage(); // Use in-memory storage for demo
}
builder.Services.AddSvrntyEventsGrpc(); // Enable gRPC event streaming
// Configure RabbitMQ for cross-service event streaming (Phase 4)
var rabbitMqEnabled = builder.Configuration.GetValue<bool>("EventStreaming:RabbitMQ:Enabled");
if (rabbitMqEnabled)
{
builder.Services.AddRabbitMQEventDelivery(options =>
{
builder.Configuration.GetSection("EventStreaming:RabbitMQ").Bind(options);
});
}
// Configure event streams and subscriptions (Phase 1.2+)
builder.Services.AddEventStreaming(streaming =>
{
// Configure stream for UserWorkflow
// Phase 4: Changed to CrossService scope to publish events to RabbitMQ
streaming.AddStream<UserWorkflow>(stream =>
{
stream.Type = StreamType.Ephemeral;
stream.DeliverySemantics = DeliverySemantics.AtLeastOnce;
stream.Scope = rabbitMqEnabled ? StreamScope.CrossService : StreamScope.Internal;
});
// // Configure stream for InvitationWorkflow (Phase 8 - temporarily disabled)
// // Phase 4: Changed to CrossService scope to publish events to RabbitMQ
// streaming.AddStream<InvitationWorkflow>(stream =>
// {
// stream.Type = StreamType.Ephemeral;
// stream.DeliverySemantics = DeliverySemantics.AtLeastOnce;
// stream.Scope = rabbitMqEnabled ? StreamScope.CrossService : StreamScope.Internal;
// });
// Add a broadcast subscription for analytics/logging
// All consumers receive all events (great for logging, analytics, audit)
streaming.AddSubscription<UserWorkflow>("user-analytics", sub =>
{
sub.Mode = SubscriptionMode.Broadcast;
sub.VisibilityTimeout = TimeSpan.FromSeconds(30);
});
// Add an exclusive subscription for processing (Phase 8 - temporarily disabled)
// Only one consumer receives each event (great for work distribution)
// streaming.AddSubscription<InvitationWorkflow>("invitation-processor", sub =>
// {
// sub.Mode = SubscriptionMode.Exclusive;
// sub.VisibilityTimeout = TimeSpan.FromSeconds(30);
// });
// Phase 5: Add a subscription with automatic upcasting enabled
// Demonstrates schema evolution - old events are automatically upgraded to latest version
streaming.AddSubscription<UserWorkflow>("user-versioning-demo", sub =>
{
sub.Mode = SubscriptionMode.Broadcast;
sub.EnableUpcasting = true; // Automatically upcast events to latest version
sub.TargetEventVersion = null; // null = upcast to latest version
sub.Description = "Phase 5: Demonstrates automatic event upcasting for schema evolution";
});
});
// Register events for discovery
builder.Services.AddEvent<UserAddedEvent>("Event emitted when a user is added to the system");
builder.Services.AddEvent<UserRemovedEvent>("Event emitted when a user is removed from the system");
builder.Services.AddEvent<UserInvitedEvent>("Event emitted when a user is invited");
builder.Services.AddEvent<UserInviteAcceptedEvent>("Event emitted when a user accepts an invitation");
builder.Services.AddEvent<UserInviteDeclinedEvent>("Event emitted when a user declines an invitation");
// Register commands and queries with validators
// NEW: Using workflow-based registration (recommended for event-emitting commands)
builder.Services.AddCommandWithWorkflow<AddUserCommand, int, UserWorkflow, AddUserCommandHandler, AddUserCommandValidator>();
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
// Register multi-step workflow commands (invite user flow)
// Phase 1: Each command creates its own workflow instance
// Future phases will support workflow continuation for true multi-step correlation
// // builder.Services.AddCommandWithWorkflow<InviteUserCommand, string, InvitationWorkflow, InviteUserCommandHandler, InviteUserCommandValidator>();
// // builder.Services.AddCommandWithWorkflow<AcceptInviteCommand, int, InvitationWorkflow, AcceptInviteCommandHandler, AcceptInviteCommandValidator>();
// // builder.Services.AddCommandWithWorkflow<DeclineInviteCommand, InvitationWorkflow, DeclineInviteCommandHandler>();
// Register event consumer background service (demonstrates Phase 1.4 event consumption)
builder.Services.AddHostedService<EventConsumerBackgroundService>();
// Register RabbitMQ event consumer (demonstrates Phase 4 cross-service communication)
if (rabbitMqEnabled)
{
builder.Services.AddHostedService<RabbitMQEventConsumerBackgroundService>();
}
// ========================================================================
// Phase 7: Event Sourcing Projections
// ========================================================================
// Register projection infrastructure
builder.Services.AddProjections(useInMemoryCheckpoints: !usePostgreSQL);
// If using PostgreSQL, register persistent checkpoint store
if (usePostgreSQL)
{
builder.Services.AddPostgresProjectionCheckpointStore();
}
// Register UserStatistics as singleton (shared across projection instances)
builder.Services.AddSingleton<UserStatistics>();
// Register UserStatisticsProjection to build read model from UserWorkflow events
builder.Services.AddDynamicProjection<UserStatisticsProjection>(
projectionName: "user-statistics",
streamName: "UserWorkflow", // Must match stream name registered above
configure: options =>
{
options.BatchSize = 50; // Process 50 events per batch
options.AutoStart = true; // Auto-start on application startup
options.MaxRetries = 3; // Retry failed events 3 times
options.CheckpointPerEvent = false; // Checkpoint after each batch (not per event)
options.AllowRebuild = true; // Allow projection rebuilds
options.PollingInterval = TimeSpan.FromSeconds(1); // Poll for new events every second
});
// ========================================================================
// Phase 7: Saga Orchestration
// ========================================================================
// Register saga infrastructure
builder.Services.AddSagaOrchestration(useInMemoryStateStore: !usePostgreSQL);
// If using PostgreSQL, register persistent state store
if (usePostgreSQL)
{
builder.Services.AddPostgresSagaStateStore();
}
// Register OrderFulfillmentSaga with compensation steps
builder.Services.AddSaga<OrderFulfillmentSaga>(
sagaName: "order-fulfillment",
configure: definition =>
{
// Step 1: Reserve inventory (can be compensated by releasing reservation)
definition.AddStep(
stepName: "ReserveInventory",
execute: OrderFulfillmentSteps.ReserveInventoryAsync,
compensate: OrderFulfillmentSteps.CompensateReserveInventoryAsync);
// Step 2: Authorize payment (can be compensated by voiding authorization)
definition.AddStep(
stepName: "AuthorizePayment",
execute: OrderFulfillmentSteps.AuthorizePaymentAsync,
compensate: OrderFulfillmentSteps.CompensateAuthorizePaymentAsync);
// Step 3: Ship order (can be compensated by cancelling shipment)
definition.AddStep(
stepName: "ShipOrder",
execute: OrderFulfillmentSteps.ShipOrderAsync,
compensate: OrderFulfillmentSteps.CompensateShipOrderAsync);
});
// ========================================================================
// Phase 8: Persistent Subscriptions & Bidirectional Communication
// ========================================================================
// Add SignalR support
// builder.Services.AddSignalR();
// Add persistent subscription infrastructure
builder.Services.AddPersistentSubscriptions(
useInMemoryStore: !usePostgreSQL,
enableBackgroundDelivery: true);
// If using PostgreSQL, register persistent subscription store
if (usePostgreSQL)
{
builder.Services.AddPostgresSubscriptionStore();
}
// Add SignalR hubs (Phase 8 - temporarily disabled)
// builder.Services.AddPersistentSubscriptionHub();
// builder.Services.AddEventStreamHub();
// Register invitation command handlers
// builder.Services.AddCommand<Svrnty.Sample.Invitations.SendInvitationCommand, Svrnty.Sample.Invitations.SendInvitationResult, Svrnty.Sample.Invitations.SendInvitationCommandHandler>();
// builder.Services.AddCommand<Svrnty.Sample.Invitations.AcceptInvitationCommand, Svrnty.Sample.Invitations.AcceptInvitationCommandHandler>();
// builder.Services.AddCommand<Svrnty.Sample.Invitations.DeclineInvitationCommand, Svrnty.Sample.Invitations.DeclineInvitationCommandHandler>();
// builder.Services.AddCommand<Svrnty.Sample.Invitations.SendInvitationReminderCommand, Svrnty.Sample.Invitations.SendInvitationReminderCommandHandler>();
// Configure CQRS with fluent API
builder.Services.AddSvrntyCqrs(cqrs =>
{
// Enable gRPC endpoints with reflection
cqrs.AddGrpc(grpc =>
{
grpc.EnableReflection();
});
// Enable MinimalApi endpoints
cqrs.AddMinimalApi(configure =>
{
});
});
builder.Services.AddEndpointsApiExplorer();
// builder.Services.AddSwaggerGen(); // Temporarily disabled due to version incompatibility
var app = builder.Build();
// Phase 5: Register event schemas for versioning
// This should be done once at application startup
var schemaRegistry = app.Services.GetRequiredService<ISchemaRegistry>();
await schemaRegistry.RegisterSchemaAsync<UserCreatedEventV1>(1); // Version 1
await schemaRegistry.RegisterSchemaAsync<UserCreatedEventV2>(2, typeof(UserCreatedEventV1)); // Version 2 (upcasts from V1)
await schemaRegistry.RegisterSchemaAsync<UserCreatedEventV3>(3, typeof(UserCreatedEventV2)); // Version 3 (upcasts from V2)
Console.WriteLine("✓ Registered 3 versions of UserCreatedEvent schema with automatic upcasting");
Console.WriteLine();
// Map all configured CQRS endpoints (gRPC, MinimalApi, and Dynamic Queries)
// This automatically maps CommandServiceImpl, QueryServiceImpl, and DynamicQueryServiceImpl
app.UseSvrntyCqrs();
// Map event streaming service (not part of auto-generated services)
app.MapGrpcService<EventServiceImpl>();
// ========================================================================
// Phase 7: Projection Query Endpoints
// ========================================================================
// Add HTTP endpoint to query user statistics projection
app.MapGet("/api/projections/user-statistics", (UserStatistics stats) =>
{
return Results.Ok(new
{
TotalAdded = stats.TotalUsersAdded,
TotalRemoved = stats.TotalUsersRemoved,
CurrentCount = stats.CurrentUserCount,
LastUpdated = stats.LastUpdated,
LastUser = new
{
Id = stats.LastUserId,
Name = stats.LastUserName,
Email = stats.LastUserEmail
}
});
})
.WithName("GetUserStatistics")
.WithTags("Projections");
// ========================================================================
// Phase 7: Saga Orchestration Endpoints
// ========================================================================
// Start a new order fulfillment saga
app.MapPost("/api/sagas/order-fulfillment/start", async (
ISagaOrchestrator orchestrator,
StartOrderRequest request) =>
{
try
{
var initialData = new Svrnty.CQRS.Events.Sagas.SagaData();
initialData.Set("OrderId", request.OrderId);
initialData.Set("Items", request.Items);
initialData.Set("Amount", request.Amount);
initialData.Set("ShippingAddress", request.ShippingAddress);
initialData.Set("FailPayment", request.SimulatePaymentFailure); // For testing compensation
var sagaId = await orchestrator.StartSagaAsync<OrderFulfillmentSaga>(
correlationId: request.OrderId,
initialData: initialData);
Console.WriteLine($"✓ Started OrderFulfillmentSaga with ID: {sagaId}");
return Results.Ok(new
{
SagaId = sagaId,
CorrelationId = request.OrderId,
Message = request.SimulatePaymentFailure
? "Saga started (payment will fail to demonstrate compensation)"
: "Saga started successfully"
});
}
catch (Exception ex)
{
return Results.Problem(ex.Message);
}
})
.WithName("StartOrderFulfillmentSaga")
.WithTags("Sagas");
// Get saga status
app.MapGet("/api/sagas/{sagaId}/status", async (
ISagaOrchestrator orchestrator,
string sagaId) =>
{
try
{
var status = await orchestrator.GetStatusAsync(sagaId);
return Results.Ok(new
{
status.SagaId,
status.CorrelationId,
status.SagaName,
State = status.State.ToString(),
Progress = $"{status.CurrentStep}/{status.TotalSteps}",
status.StartedAt,
status.LastUpdated,
status.CompletedAt,
status.ErrorMessage,
status.Data
});
}
catch (Exception ex)
{
return Results.Problem(ex.Message);
}
})
.WithName("GetSagaStatus")
.WithTags("Sagas");
// Cancel a running saga
app.MapPost("/api/sagas/{sagaId}/cancel", async (
ISagaOrchestrator orchestrator,
string sagaId) =>
{
try
{
await orchestrator.CancelSagaAsync(sagaId);
return Results.Ok(new
{
SagaId = sagaId,
Message = "Saga cancellation initiated (compensation in progress)"
});
}
catch (Exception ex)
{
return Results.Problem(ex.Message);
}
})
.WithName("CancelSaga")
.WithTags("Sagas");
// ========================================================================
// Phase 8: Persistent Subscription Endpoints
// ========================================================================
// Map SignalR hubs (Phase 8 - temporarily disabled)
// app.MapPersistentSubscriptionHub("/hubs/subscriptions");
// app.MapEventStreamHub("/hubs/events");
// Map invitation endpoints
// app.MapInvitationEndpoints();
// app.UseSwagger(); // Temporarily disabled
// app.UseSwaggerUI();
Console.WriteLine("=== Svrnty CQRS Sample with Event Streaming ===");
Console.WriteLine();
Console.WriteLine("gRPC (HTTP/2): http://localhost:6000");
Console.WriteLine(" - CommandService, QueryService, DynamicQueryService");
Console.WriteLine(" - EventService (bidirectional streaming)");
Console.WriteLine();
Console.WriteLine("HTTP API (HTTP/1.1): http://localhost:6001");
Console.WriteLine(" - Commands: POST /api/command/*");
Console.WriteLine(" - Queries: GET/POST /api/query/*");
Console.WriteLine(" - Swagger UI: http://localhost:6001/swagger");
Console.WriteLine();
Console.WriteLine("Event Streams Configured:");
Console.WriteLine(" - UserWorkflow stream (ephemeral, at-least-once, {0})",
rabbitMqEnabled ? "external via RabbitMQ" : "internal");
// Console.WriteLine(" - InvitationWorkflow stream (ephemeral, at-least-once, {0})",
// rabbitMqEnabled ? "external via RabbitMQ" : "internal");
Console.WriteLine();
Console.WriteLine("Subscriptions Active:");
Console.WriteLine(" - user-analytics (broadcast mode, internal)");
Console.WriteLine(" - invitation-processor (exclusive mode, internal)");
Console.WriteLine(" - user-versioning-demo (broadcast mode, with auto-upcasting enabled)");
if (rabbitMqEnabled)
{
Console.WriteLine(" - email-service (consumer group mode, RabbitMQ)");
}
Console.WriteLine();
Console.WriteLine("Schema Evolution (Phase 5):");
Console.WriteLine(" - UserCreatedEvent: 3 versions registered (V1 → V2 → V3)");
Console.WriteLine(" - Auto-upcasting: Enabled on user-versioning-demo subscription");
Console.WriteLine(" - JSON Schema: Auto-generated for external consumers");
Console.WriteLine();
Console.WriteLine("Event Sourcing Projections (Phase 7.1):");
Console.WriteLine(" - user-statistics: Tracks user statistics from UserWorkflow events");
Console.WriteLine(" - Query endpoint: GET /api/projections/user-statistics");
Console.WriteLine(" - Checkpoint storage: {0}", usePostgreSQL ? "PostgreSQL (persistent)" : "In-memory");
Console.WriteLine();
Console.WriteLine("Saga Orchestration (Phase 7.3):");
Console.WriteLine(" - order-fulfillment: Multi-step workflow with compensation");
Console.WriteLine(" - Start saga: POST /api/sagas/order-fulfillment/start");
Console.WriteLine(" - Get status: GET /api/sagas/{{sagaId}}/status");
Console.WriteLine(" - Cancel saga: POST /api/sagas/{{sagaId}}/cancel");
Console.WriteLine(" - State storage: {0}", usePostgreSQL ? "PostgreSQL (persistent)" : "In-memory");
Console.WriteLine();
Console.WriteLine("Persistent Subscriptions (Phase 8):");
Console.WriteLine(" - SignalR Hub: ws://localhost:6001/hubs/subscriptions");
Console.WriteLine(" - Event Stream Hub: ws://localhost:6001/hubs/events");
Console.WriteLine(" - Invitation workflow: Demonstrates correlation-based subscriptions");
Console.WriteLine(" - Send invitation: POST /api/invitations/send");
Console.WriteLine(" - Accept invitation: POST /api/invitations/{{id}}/accept");
Console.WriteLine(" - Decline invitation: POST /api/invitations/{{id}}/decline");
Console.WriteLine(" - Subscription storage: {0}", usePostgreSQL ? "PostgreSQL (persistent)" : "In-memory");
Console.WriteLine(" - Background delivery: Enabled");
Console.WriteLine();
if (rabbitMqEnabled)
{
Console.WriteLine("RabbitMQ Configuration:");
Console.WriteLine(" - Connection: amqp://localhost:5672");
Console.WriteLine(" - Exchange Prefix: svrnty-sample");
Console.WriteLine(" - Management UI: http://localhost:15672 (guest/guest)");
Console.WriteLine();
}
Console.WriteLine("Infrastructure:");
Console.WriteLine(" - PostgreSQL: localhost:5432 (svrnty_events)");
if (rabbitMqEnabled)
{
Console.WriteLine(" - RabbitMQ: localhost:5672");
}
Console.WriteLine();
Console.WriteLine("To start infrastructure:");
Console.WriteLine(" docker-compose up -d");
Console.WriteLine();
app.Run();
// ========================================================================
// Request Models
// ========================================================================
/// <summary>
/// Request model for starting an order fulfillment saga.
/// </summary>
public sealed record StartOrderRequest
{
public required string OrderId { get; init; }
public required List<Svrnty.Sample.Sagas.OrderItem> Items { get; init; }
public decimal Amount { get; init; }
public required string ShippingAddress { get; init; }
public bool SimulatePaymentFailure { get; init; } = false;
}