diff --git a/docs/examples/order-event-sourcing-pattern.md b/docs/examples/order-event-sourcing-pattern.md new file mode 100644 index 0000000..0ede4be --- /dev/null +++ b/docs/examples/order-event-sourcing-pattern.md @@ -0,0 +1,28 @@ +# Order Event Sourcing Pattern + +This production-shaped example shows an order workflow backed by append-only events and replayed projections. + +It demonstrates: + +- fluent `InMemoryEventStore` construction +- generated event store factory with `[GenerateEventStore]` +- optimistic concurrency through expected stream versions +- scoped `IEventStore` registration through `IServiceCollection` + +```csharp +var services = new ServiceCollection(); +services.AddOrderEventSourcingDemo(); + +using var provider = services.BuildServiceProvider(); +using var scope = provider.CreateScope(); + +var workflow = scope.ServiceProvider.GetRequiredService(); +var summary = await workflow.PlaceAndPayAsync("order-100", "customer-1", 125m, "payment-1"); +``` + +The registered event store is scoped so importing applications can compose it with request-scoped storage adapters, database sessions, tenant services, or unit-of-work boundaries. + +Files: + +- `src/PatternKit.Examples/EventSourcingDemo/OrderEventSourcingDemo.cs` +- `test/PatternKit.Examples.Tests/EventSourcingDemo/OrderEventSourcingDemoTests.cs` diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 78ec0ae..1f0d698 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -103,6 +103,9 @@ - name: Order Table Data Gateway Pattern href: order-table-data-gateway-pattern.md +- name: Order Event Sourcing Pattern + href: order-event-sourcing-pattern.md + - name: Generated Mailbox href: generated-mailbox.md diff --git a/docs/generators/event-sourcing.md b/docs/generators/event-sourcing.md new file mode 100644 index 0000000..b877e02 --- /dev/null +++ b/docs/generators/event-sourcing.md @@ -0,0 +1,20 @@ +# Event Sourcing Generator + +`GenerateEventStoreAttribute` creates a typed `InMemoryEventStore` factory for an event stream. + +```csharp +[GenerateEventStore(typeof(OrderEvent), typeof(string), FactoryName = "CreateStore", StoreName = "order-events")] +public static partial class GeneratedOrderEventStore; +``` + +The generated factory is equivalent to: + +```csharp +InMemoryEventStore + .Create("order-events") + .Build(); +``` + +Diagnostics: + +- `PKES001`: host type must be partial. diff --git a/docs/generators/index.md b/docs/generators/index.md index cce20e4..10034f7 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -68,6 +68,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Service Layer**](service-layer.md) | Application operation boundary factories | `[GenerateServiceLayerOperation]` | | [**Domain Event**](domain-event.md) | Domain event dispatcher factories | `[GenerateDomainEventDispatcher]` | | [**Table Data Gateway**](table-data-gateway.md) | Row gateway factories from key selectors | `[GenerateTableDataGateway]` | +| [**Event Sourcing**](event-sourcing.md) | Append-only event store factories | `[GenerateEventStore]` | | [**Template Method**](template-method-generator.md) | Template method skeletons with hook points | `[Template]` | | [**Visitor**](visitor-generator.md) | Type-safe visitor implementations | `[GenerateVisitor]` | diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index de5490d..590e3b9 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -43,6 +43,9 @@ - name: Domain Event href: domain-event.md +- name: Event Sourcing + href: event-sourcing.md + - name: Dispatcher href: dispatcher.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 1489859..447d509 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -75,6 +75,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Application Architecture | Service Layer | `IServiceOperation` and `ServiceLayerOperation` | Service Layer generator | | Application Architecture | Domain Event | `IDomainEvent` and `DomainEventDispatcher` | Domain Event generator | | Application Architecture | Table Data Gateway | `ITableDataGateway` and `InMemoryTableDataGateway` | Table Data Gateway generator | +| Application Architecture | Event Sourcing | `IEventStore` and `InMemoryEventStore` | Event Sourcing generator | | Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer` | Anti-Corruption Layer generator | ## Research Baselines diff --git a/docs/patterns/application/event-sourcing.md b/docs/patterns/application/event-sourcing.md new file mode 100644 index 0000000..3599bc3 --- /dev/null +++ b/docs/patterns/application/event-sourcing.md @@ -0,0 +1,28 @@ +# Event Sourcing + +Event Sourcing stores domain changes as an append-only stream of facts and rebuilds state by replaying those facts. Use it when auditability, temporal history, integration handoff, or projection rebuilds are first-class requirements. + +PatternKit provides `IEventStore` and `InMemoryEventStore` in `PatternKit.Application.EventSourcing`. + +```csharp +var store = InMemoryEventStore + .Create("order-events") + .Build(); + +await store.AppendAsync("order-100", 0, [ + new OrderPlaced("order-100", "customer-1", 125m, DateTimeOffset.UtcNow), + new OrderPaid("order-100", "payment-1", DateTimeOffset.UtcNow) +]); + +var stream = await store.ReadStreamAsync("order-100"); +var summary = OrderProjection.Project(store.Name, stream); +``` + +Appends require an expected version. A stale expected version returns `EventStoreAppendStatus.Conflict` and does not mutate the stream, giving callers an optimistic concurrency boundary. + +Use the source-generated path when the event base type and stream identity type are stable. Register `IEventStore` as scoped when the store is backed by request-scoped database sessions, tenant boundaries, or unit-of-work infrastructure. + +See also: + +- [Event Sourcing generator](../../generators/event-sourcing.md) +- [Order Event Sourcing example](../../examples/order-event-sourcing-pattern.md) diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index cc317ce..329c3d3 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -349,6 +349,8 @@ href: application/domain-event.md - name: Table Data Gateway href: application/table-data-gateway.md + - name: Event Sourcing + href: application/event-sourcing.md - name: Specification href: application/specification.md - name: Type-Dispatcher diff --git a/src/PatternKit.Core/Application/EventSourcing/EventStore.cs b/src/PatternKit.Core/Application/EventSourcing/EventStore.cs new file mode 100644 index 0000000..cc015ee --- /dev/null +++ b/src/PatternKit.Core/Application/EventSourcing/EventStore.cs @@ -0,0 +1,169 @@ +namespace PatternKit.Application.EventSourcing; + +public interface IEventStore + where TStreamId : notnull +{ + string Name { get; } + + ValueTask AppendAsync(TStreamId streamId, long expectedVersion, IEnumerable events, CancellationToken cancellationToken = default); + + ValueTask>> ReadStreamAsync(TStreamId streamId, CancellationToken cancellationToken = default); +} + +public sealed class InMemoryEventStore : IEventStore + where TStreamId : notnull +{ + private readonly object _gate = new(); + private readonly Dictionary>> _streams; + + private InMemoryEventStore(string name, IEqualityComparer? comparer) + { + Name = name; + _streams = new Dictionary>>(comparer); + } + + public string Name { get; } + + public static Builder Create(string name) => new(name); + + public ValueTask AppendAsync(TStreamId streamId, long expectedVersion, IEnumerable events, CancellationToken cancellationToken = default) + { + if (streamId is null) + throw new ArgumentNullException(nameof(streamId)); + if (events is null) + throw new ArgumentNullException(nameof(events)); + if (expectedVersion < 0) + throw new ArgumentOutOfRangeException(nameof(expectedVersion)); + + var pending = events.ToArray(); + if (pending.Length == 0) + throw new ArgumentException("Event stream append requires at least one event.", nameof(events)); + if (pending.Any(static @event => @event is null)) + throw new ArgumentException("Event stream cannot contain null events.", nameof(events)); + + cancellationToken.ThrowIfCancellationRequested(); + lock (_gate) + { + var currentVersion = _streams.TryGetValue(streamId, out var stream) ? stream.Count : 0; + if (currentVersion != expectedVersion) + return new(EventStoreAppendResult.Conflict(currentVersion, expectedVersion)); + + if (stream is null) + { + stream = new List>(); + _streams[streamId] = stream; + } + + var appended = 0; + foreach (var @event in pending) + { + appended++; + stream.Add(new StoredEvent(streamId, currentVersion + appended, @event, DateTimeOffset.UtcNow)); + } + + return new(EventStoreAppendResult.Commit(currentVersion + appended, appended)); + } + } + + public ValueTask>> ReadStreamAsync(TStreamId streamId, CancellationToken cancellationToken = default) + { + if (streamId is null) + throw new ArgumentNullException(nameof(streamId)); + + cancellationToken.ThrowIfCancellationRequested(); + lock (_gate) + { + return new(_streams.TryGetValue(streamId, out var stream) + ? stream.ToArray() + : Array.Empty>()); + } + } + + public sealed class Builder + { + private readonly string _name; + private IEqualityComparer? _comparer; + + internal Builder(string name) + { + _name = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Event store name is required.", nameof(name)) + : name; + } + + public Builder UseComparer(IEqualityComparer comparer) + { + _comparer = comparer ?? throw new ArgumentNullException(nameof(comparer)); + return this; + } + + public InMemoryEventStore Build() => new(_name, _comparer); + } +} + +public sealed class StoredEvent + where TStreamId : notnull +{ + public StoredEvent(TStreamId streamId, long version, TEvent @event, DateTimeOffset storedAt) + { + StreamId = streamId; + Version = version; + Event = @event; + StoredAt = storedAt; + } + + public TStreamId StreamId { get; } + + public long Version { get; } + + public TEvent Event { get; } + + public DateTimeOffset StoredAt { get; } +} + +public sealed class EventStoreAppendResult +{ + private EventStoreAppendResult(EventStoreAppendStatus status, long version, long expectedVersion, int appendedCount) + { + Status = status; + Version = version; + ExpectedVersion = expectedVersion; + AppendedCount = appendedCount; + } + + public EventStoreAppendStatus Status { get; } + + public long Version { get; } + + public long ExpectedVersion { get; } + + public int AppendedCount { get; } + + public bool Committed => Status == EventStoreAppendStatus.Committed; + + public static EventStoreAppendResult Commit(long version, int appendedCount) + { + if (version < 0) + throw new ArgumentOutOfRangeException(nameof(version)); + if (appendedCount < 0) + throw new ArgumentOutOfRangeException(nameof(appendedCount)); + + return new(EventStoreAppendStatus.Committed, version, version, appendedCount); + } + + public static EventStoreAppendResult Conflict(long currentVersion, long expectedVersion) + { + if (currentVersion < 0) + throw new ArgumentOutOfRangeException(nameof(currentVersion)); + if (expectedVersion < 0) + throw new ArgumentOutOfRangeException(nameof(expectedVersion)); + + return new(EventStoreAppendStatus.Conflict, currentVersion, expectedVersion, 0); + } +} + +public enum EventStoreAppendStatus +{ + Committed, + Conflict +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index a8b9aa3..a407fe7 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -26,6 +26,7 @@ using PatternKit.Examples.DataMapperDemo; using PatternKit.Examples.DomainEventDemo; using PatternKit.Examples.EnterpriseFeatureSlices; +using PatternKit.Examples.EventSourcingDemo; using PatternKit.Examples.FlyweightDemo; using PatternKit.Examples.Generators.Builders.CorporateApplicationBuilderDemo; using PatternKit.Examples.Generators.Visitors; @@ -136,6 +137,7 @@ public sealed record OrderTransactionScriptPatternExample(OrderTransactionScript public sealed record CustomerServiceLayerPatternExample(CustomerServiceLayerDemoRunner Runner); public sealed record OrderDomainEventPatternExample(OrderDomainEventDemoRunner Runner); public sealed record OrderTableDataGatewayPatternExample(OrderTableDataGatewayDemoRunner Runner); +public sealed record OrderEventSourcingPatternExample(OrderEventSourcingDemoRunner Runner); public sealed record PrototypeGameCharacterFactoryExample(Prototype Factory); public sealed record ProxyPatternDemonstrationsExample(Proxy RemoteProxy, Proxy<(string To, string Subject, string Body), bool> EmailProxy); public sealed record FlyweightGlyphCacheExample(Func> RenderSentence); @@ -200,6 +202,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddCustomerServiceLayerPatternExample() .AddOrderDomainEventPatternExample() .AddOrderTableDataGatewayPatternExample() + .AddOrderEventSourcingPatternExample() .AddPrototypeGameCharacterFactoryExample() .AddProxyPatternDemonstrationsExample() .AddFlyweightGlyphCacheExample() @@ -594,6 +597,13 @@ public static IServiceCollection AddOrderTableDataGatewayPatternExample(this ISe return services.RegisterExample("Order Table Data Gateway Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddOrderEventSourcingPatternExample(this IServiceCollection services) + { + services.AddOrderEventSourcingDemo(); + services.AddSingleton(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Order Event Sourcing Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddPrototypeGameCharacterFactoryExample(this IServiceCollection services) { services.AddSingleton(_ => PrototypeDemo.PrototypeDemo.CreateCharacterFactory()); diff --git a/src/PatternKit.Examples/EventSourcingDemo/OrderEventSourcingDemo.cs b/src/PatternKit.Examples/EventSourcingDemo/OrderEventSourcingDemo.cs new file mode 100644 index 0000000..d6bfd6e --- /dev/null +++ b/src/PatternKit.Examples/EventSourcingDemo/OrderEventSourcingDemo.cs @@ -0,0 +1,126 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.EventSourcing; +using PatternKit.Generators.EventSourcing; + +namespace PatternKit.Examples.EventSourcingDemo; + +public static class OrderEventSourcingDemo +{ + public static async ValueTask RunFluentAsync() + { + var store = OrderEventSourcingPolicies.CreateFluentStore(); + return await RunScenarioAsync(store, "order-100"); + } + + public static async ValueTask RunGeneratedAsync() + => await RunScenarioAsync(GeneratedOrderEventStore.CreateStore(), "order-200"); + + private static async ValueTask RunScenarioAsync(IEventStore store, string orderId) + { + _ = await store.AppendAsync(orderId, 0, [ + new OrderPlaced(orderId, "customer-1", 125m, DateTimeOffset.UtcNow), + new OrderPaid(orderId, "payment-1", DateTimeOffset.UtcNow) + ]); + var stream = await store.ReadStreamAsync(orderId); + return OrderProjection.Project(store.Name, stream); + } +} + +public abstract record OrderEvent(string OrderId, DateTimeOffset OccurredAt); + +public sealed record OrderPlaced(string OrderId, string CustomerId, decimal Total, DateTimeOffset OccurredAt) + : OrderEvent(OrderId, OccurredAt); + +public sealed record OrderPaid(string OrderId, string PaymentId, DateTimeOffset OccurredAt) + : OrderEvent(OrderId, OccurredAt); + +public sealed record OrderEventSourcingSummary( + string StoreName, + string OrderId, + string CustomerId, + decimal Total, + bool Paid, + long Version); + +public static class OrderEventSourcingPolicies +{ + public static InMemoryEventStore CreateFluentStore() + => InMemoryEventStore.Create("order-events").Build(); +} + +public static class OrderProjection +{ + public static OrderEventSourcingSummary Project(string storeName, IReadOnlyList> stream) + { + var orderId = ""; + var customerId = ""; + var total = 0m; + var paid = false; + var version = 0L; + + foreach (var stored in stream.OrderBy(static entry => entry.Version)) + { + version = stored.Version; + switch (stored.Event) + { + case OrderPlaced placed: + orderId = placed.OrderId; + customerId = placed.CustomerId; + total = placed.Total; + break; + case OrderPaid paidEvent: + orderId = paidEvent.OrderId; + paid = true; + break; + } + } + + return new(storeName, orderId, customerId, total, paid, version); + } +} + +public sealed class OrderEventSourcingWorkflow +{ + private readonly IEventStore _store; + + public OrderEventSourcingWorkflow(IEventStore store) + { + _store = store; + } + + public async ValueTask PlaceAndPayAsync( + string orderId, + string customerId, + decimal total, + string paymentId, + CancellationToken cancellationToken = default) + { + _ = await _store.AppendAsync(orderId, 0, [ + new OrderPlaced(orderId, customerId, total, DateTimeOffset.UtcNow), + new OrderPaid(orderId, paymentId, DateTimeOffset.UtcNow) + ], cancellationToken).ConfigureAwait(false); + + var stream = await _store.ReadStreamAsync(orderId, cancellationToken).ConfigureAwait(false); + return OrderProjection.Project(_store.Name, stream); + } +} + +public sealed record OrderEventSourcingDemoRunner( + Func> RunFluentAsync, + Func> RunGeneratedAsync); + +public static class OrderEventSourcingServiceCollectionExtensions +{ + public static IServiceCollection AddOrderEventSourcingDemo(this IServiceCollection services) + { + services.AddScoped>(_ => OrderEventSourcingPolicies.CreateFluentStore()); + services.AddScoped(); + services.AddSingleton(new OrderEventSourcingDemoRunner( + OrderEventSourcingDemo.RunFluentAsync, + OrderEventSourcingDemo.RunGeneratedAsync)); + return services; + } +} + +[GenerateEventStore(typeof(OrderEvent), typeof(string), FactoryName = "CreateStore", StoreName = "order-events")] +public static partial class GeneratedOrderEventStore; diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 326d190..b6ae9af 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -360,6 +360,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["TableDataGateway"], ["row-oriented table boundary", "source-generated gateway factory", "DI composition"]), + Descriptor( + "Order Event Sourcing Pattern", + "src/PatternKit.Examples/EventSourcingDemo/OrderEventSourcingDemo.cs", + "test/PatternKit.Examples.Tests/EventSourcingDemo/OrderEventSourcingDemoTests.cs", + "docs/examples/order-event-sourcing-pattern.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["EventSourcing"], + ["append-only stream", "source-generated event store factory", "DI composition"]), Descriptor( "Generated Mailbox", "src/PatternKit.Examples/Messaging/MailboxExample.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 8a485cc..4246bfd 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -765,6 +765,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/TableDataGatewayDemo/OrderTableDataGatewayDemoTests.cs", ["fluent row gateway", "generated table gateway factory", "DI-importable row workflow"]), + Pattern("Event Sourcing", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/event-sourcing.md", + "src/PatternKit.Core/Application/EventSourcing/EventStore.cs", + "test/PatternKit.Tests/Application/EventSourcing/EventStoreTests.cs", + "docs/generators/event-sourcing.md", + "src/PatternKit.Generators/EventSourcing/EventStoreGenerator.cs", + "test/PatternKit.Generators.Tests/EventStoreGeneratorTests.cs", + null, + "docs/examples/order-event-sourcing-pattern.md", + "src/PatternKit.Examples/EventSourcingDemo/OrderEventSourcingDemo.cs", + "test/PatternKit.Examples.Tests/EventSourcingDemo/OrderEventSourcingDemoTests.cs", + ["fluent append-only event store", "generated event store factory", "DI-importable replay workflow"]), + Pattern("Anti-Corruption Layer", PatternFamily.ApplicationArchitecture, "docs/patterns/application/anti-corruption-layer.md", "src/PatternKit.Core/Application/AntiCorruption/AntiCorruptionLayer.cs", diff --git a/src/PatternKit.Generators.Abstractions/EventSourcing/EventSourcingAttributes.cs b/src/PatternKit.Generators.Abstractions/EventSourcing/EventSourcingAttributes.cs new file mode 100644 index 0000000..481270c --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/EventSourcing/EventSourcingAttributes.cs @@ -0,0 +1,19 @@ +namespace PatternKit.Generators.EventSourcing; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateEventStoreAttribute : Attribute +{ + public GenerateEventStoreAttribute(Type eventType, Type streamIdType) + { + EventType = eventType ?? throw new ArgumentNullException(nameof(eventType)); + StreamIdType = streamIdType ?? throw new ArgumentNullException(nameof(streamIdType)); + } + + public Type EventType { get; } + + public Type StreamIdType { get; } + + public string FactoryName { get; set; } = "Create"; + + public string StoreName { get; set; } = ""; +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 744162e..0426af4 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -98,6 +98,7 @@ PKDE001 | PatternKit.Generators.DomainEvents | Error | Domain Event dispatcher h PKDE002 | PatternKit.Generators.DomainEvents | Error | Domain Event dispatcher must declare at least one handler. PKDE003 | PatternKit.Generators.DomainEvents | Error | Domain Event handler signature is invalid. PKDE004 | PatternKit.Generators.DomainEvents | Error | Domain Event handler order is duplicated. +PKES001 | PatternKit.Generators.EventSourcing | Error | Event Store host must be partial. PKPRO001 | PatternKit.Generators.Prototype | Error | Type marked with [Prototype] must be partial PKPRO002 | PatternKit.Generators.Prototype | Error | Cannot construct clone target (no supported clone construction path) PKPRO003 | PatternKit.Generators.Prototype | Warning | Unsafe reference capture (mutable reference types) diff --git a/src/PatternKit.Generators/EventSourcing/EventStoreGenerator.cs b/src/PatternKit.Generators/EventSourcing/EventStoreGenerator.cs new file mode 100644 index 0000000..f46a1e4 --- /dev/null +++ b/src/PatternKit.Generators/EventSourcing/EventStoreGenerator.cs @@ -0,0 +1,104 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.EventSourcing; + +[Generator] +public sealed class EventStoreGenerator : IIncrementalGenerator +{ + private const string GenerateAttributeName = "PatternKit.Generators.EventSourcing.GenerateEventStoreAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKES001", "Event Store host must be partial", + "Type '{0}' is marked with [GenerateEventStore] but is not declared as partial", + "PatternKit.Generators.EventSourcing", DiagnosticSeverity.Error, true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + GenerateAttributeName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(static a => a.AttributeClass?.ToDisplayString() == GenerateAttributeName); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate(SourceProductionContext context, INamedTypeSymbol type, TypeDeclarationSyntax node, AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var eventType = attribute.ConstructorArguments.Length > 0 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + var streamIdType = attribute.ConstructorArguments.Length > 1 ? attribute.ConstructorArguments[1].Value as INamedTypeSymbol : null; + if (eventType is null || streamIdType is null) + return; + + var storeName = GetNamedString(attribute, "StoreName"); + if (string.IsNullOrWhiteSpace(storeName)) + storeName = type.Name; + + context.AddSource($"{type.Name}.EventStore.g.cs", SourceText.From( + GenerateSource(type, eventType, streamIdType, GetNamedString(attribute, "FactoryName") ?? "Create", storeName!), + Encoding.UTF8)); + } + + private static string GenerateSource(INamedTypeSymbol type, INamedTypeSymbol eventType, INamedTypeSymbol streamIdType, string factoryName, string storeName) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var eventName = eventType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var streamIdName = streamIdType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + if (type.IsStatic) + sb.Append("static "); + else if (type.IsAbstract && type.TypeKind == TypeKind.Class) + sb.Append("abstract "); + else if (type.IsSealed && type.TypeKind == TypeKind.Class) + sb.Append("sealed "); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Application.EventSourcing.InMemoryEventStore<") + .Append(eventName).Append(", ").Append(streamIdName).Append("> ").Append(factoryName).AppendLine("()"); + sb.Append(" => global::PatternKit.Application.EventSourcing.InMemoryEventStore<") + .Append(eventName).Append(", ").Append(streamIdName).Append(">.Create(\"").Append(Escape(storeName)).AppendLine("\").Build();"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; +} diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index 18f4250..8f34e04 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -114,6 +114,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() var serviceLayer = provider.GetRequiredService(); var domainEvents = provider.GetRequiredService(); var tableGateway = provider.GetRequiredService(); + var eventSourcing = provider.GetRequiredService(); var inventoryRetry = provider.GetRequiredService(); var fulfillmentBreaker = provider.GetRequiredService(); var shippingBulkhead = provider.GetRequiredService(); @@ -192,6 +193,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("service layer example registers customers", serviceLayer.Runner.RunFluentAsync().AsTask().GetAwaiter().GetResult().Registered), ("domain event example dispatches order events", domainEvents.Runner.RunFluentAsync().AsTask().GetAwaiter().GetResult().Dispatched), ("table data gateway example queries order rows", tableGateway.Runner.RunFluentAsync().AsTask().GetAwaiter().GetResult().ClosedOrderCount == 1), + ("event sourcing example replays paid order streams", eventSourcing.Runner.RunFluentAsync().AsTask().GetAwaiter().GetResult().Paid), ("generated retry policy recovers inventory lookups", inventoryRetry.Service.CheckAsync("SKU-42").GetAwaiter().GetResult().Available), ("generated circuit breaker isolates fulfillment outages", CircuitBreakerOpens(fulfillmentBreaker.Service)), ("generated bulkhead reserves shipping allocations", shippingBulkhead.Service.ReserveAsync("ORDER-100").GetAwaiter().GetResult().Succeeded), diff --git a/test/PatternKit.Examples.Tests/EventSourcingDemo/OrderEventSourcingDemoTests.cs b/test/PatternKit.Examples.Tests/EventSourcingDemo/OrderEventSourcingDemoTests.cs new file mode 100644 index 0000000..c112dbd --- /dev/null +++ b/test/PatternKit.Examples.Tests/EventSourcingDemo/OrderEventSourcingDemoTests.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.EventSourcingDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.EventSourcingDemo; + +[Feature("Order Event Sourcing demo")] +public sealed partial class OrderEventSourcingDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Order Event Sourcing demo replays an order stream")] + [Theory] + [InlineData(false)] + [InlineData(true)] + public Task Order_Event_Sourcing_Demo_Replays_An_Order_Stream(bool sourceGenerated) + => Given("the order event sourcing demo", () => sourceGenerated) + .When("the selected path runs", (Func>)(async generated => + generated + ? await OrderEventSourcingDemo.RunGeneratedAsync() + : await OrderEventSourcingDemo.RunFluentAsync())) + .Then("the replayed order is paid", summary => + { + ScenarioExpect.Equal("order-events", summary.StoreName); + ScenarioExpect.Equal(125m, summary.Total); + ScenarioExpect.True(summary.Paid); + ScenarioExpect.Equal(2, summary.Version); + ScenarioExpect.False(string.IsNullOrWhiteSpace(summary.OrderId)); + }) + .AssertPassed(); + + [Scenario("Order Event Sourcing demo is importable through IServiceCollection")] + [Fact] + public Task Order_Event_Sourcing_Demo_Is_Importable_Through_IServiceCollection() + => Given("a service provider with the order event sourcing demo", () => + { + var services = new ServiceCollection(); + services.AddOrderEventSourcingDemo(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("a scoped workflow places and pays an order", (Func>)(async provider => + { + using (provider) + using (var scope = provider.CreateScope()) + { + var workflow = scope.ServiceProvider.GetRequiredService(); + return await workflow.PlaceAndPayAsync("order-300", "customer-3", 50m, "payment-3"); + } + })) + .Then("the imported event store replays the order state", summary => + { + ScenarioExpect.Equal("order-events", summary.StoreName); + ScenarioExpect.Equal("order-300", summary.OrderId); + ScenarioExpect.Equal("customer-3", summary.CustomerId); + ScenarioExpect.Equal(50m, summary.Total); + ScenarioExpect.True(summary.Paid); + ScenarioExpect.Equal(2, summary.Version); + }) + .AssertPassed(); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 39ef9ca..6eb2cb8 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -69,6 +69,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Service Layer", "Domain Event", "Table Data Gateway", + "Event Sourcing", "Anti-Corruption Layer" ]; @@ -113,7 +114,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() ScenarioExpect.Equal(13, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(5, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); - ScenarioExpect.Equal(11, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(12, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 26a985b..ce82c41 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -10,6 +10,7 @@ using PatternKit.Generators.DataMapping; using PatternKit.Generators.Decorator; using PatternKit.Generators.DomainEvents; +using PatternKit.Generators.EventSourcing; using PatternKit.Generators.Facade; using PatternKit.Generators.Flyweight; using PatternKit.Generators.Factories; @@ -90,6 +91,7 @@ private enum TestTrigger { typeof(DataMapperToDomainAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateDomainEventDispatcherAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(DomainEventHandlerAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateEventStoreAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GenerateFacadeAttribute), AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Struct, true, false }, { typeof(FacadeExposeAttribute), AttributeTargets.Method, false, false }, { typeof(FacadeMapAttribute), AttributeTargets.Method, false, false }, @@ -1049,6 +1051,11 @@ public void State_And_Template_Attributes_Expose_Defaults_And_Configuration() DispatcherName = "order-events" }; var domainEventHandler = new DomainEventHandlerAttribute(typeof(string), 20); + var eventStore = new GenerateEventStoreAttribute(typeof(string), typeof(Guid)) + { + FactoryName = "BuildOrderEvents", + StoreName = "order-events" + }; var tableGateway = new GenerateTableDataGatewayAttribute(typeof(string), typeof(int)) { FactoryName = "BuildOrderTable", @@ -1101,6 +1108,10 @@ public void State_And_Template_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.Equal("order-events", domainEvents.DispatcherName); ScenarioExpect.Equal(typeof(string), domainEventHandler.EventType); ScenarioExpect.Equal(20, domainEventHandler.Order); + ScenarioExpect.Equal(typeof(string), eventStore.EventType); + ScenarioExpect.Equal(typeof(Guid), eventStore.StreamIdType); + ScenarioExpect.Equal("BuildOrderEvents", eventStore.FactoryName); + ScenarioExpect.Equal("order-events", eventStore.StoreName); ScenarioExpect.Equal(typeof(string), tableGateway.RowType); ScenarioExpect.Equal(typeof(int), tableGateway.KeyType); ScenarioExpect.Equal("BuildOrderTable", tableGateway.FactoryName); @@ -1114,6 +1125,8 @@ public void State_And_Template_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.Throws(() => new ServiceLayerRuleAttribute("code", "", 1)); ScenarioExpect.Throws(() => new GenerateDomainEventDispatcherAttribute(null!)); ScenarioExpect.Throws(() => new DomainEventHandlerAttribute(null!, 1)); + ScenarioExpect.Throws(() => new GenerateEventStoreAttribute(null!, typeof(Guid))); + ScenarioExpect.Throws(() => new GenerateEventStoreAttribute(typeof(string), null!)); ScenarioExpect.Throws(() => new GenerateTableDataGatewayAttribute(null!, typeof(int))); ScenarioExpect.Throws(() => new GenerateTableDataGatewayAttribute(typeof(string), null!)); ScenarioExpect.IsType(new TableGatewayKeySelectorAttribute()); diff --git a/test/PatternKit.Generators.Tests/EventStoreGeneratorTests.cs b/test/PatternKit.Generators.Tests/EventStoreGeneratorTests.cs new file mode 100644 index 0000000..4651d20 --- /dev/null +++ b/test/PatternKit.Generators.Tests/EventStoreGeneratorTests.cs @@ -0,0 +1,57 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Application.EventSourcing; +using PatternKit.Generators.EventSourcing; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Event Store generator")] +public sealed partial class EventStoreGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generator emits event store factory")] + [Fact] + public Task Generator_Emits_Event_Store_Factory() + => Given("a valid event store declaration", () => Compile(""" + using PatternKit.Generators.EventSourcing; + namespace Demo; + public abstract record OrderEvent(string OrderId); + [GenerateEventStore(typeof(OrderEvent), typeof(string), FactoryName = "Build", StoreName = "order-events")] + public static partial class OrderEventStore; + """)) + .Then("generated source creates the store", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("Build()", source); + ScenarioExpect.Contains("InMemoryEventStore.Create(\"order-events\").Build()", source); + }) + .AssertPassed(); + + [Scenario("Generator reports invalid event store declarations")] + [Fact] + public Task Generator_Reports_Invalid_Event_Store_Declarations() + => Given("a non-partial event store declaration", () => Compile(""" + using PatternKit.Generators.EventSourcing; + public abstract record OrderEvent(string OrderId); + [GenerateEventStore(typeof(OrderEvent), typeof(string))] + public static class OrderEventStore; + """)) + .Then("the partial diagnostic is reported", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == "PKES001")) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "EventStoreGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(InMemoryEventStore<,>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new EventStoreGenerator(), out var run, out _); + var result = run.Results.Single(); + return new GeneratorResult(result.Diagnostics.ToArray(), result.GeneratedSources.Select(static source => source.SourceText.ToString()).ToArray()); + } + + private sealed record GeneratorResult(IReadOnlyList Diagnostics, IReadOnlyList GeneratedSources); +} diff --git a/test/PatternKit.Tests/Application/EventSourcing/EventStoreTests.cs b/test/PatternKit.Tests/Application/EventSourcing/EventStoreTests.cs new file mode 100644 index 0000000..e1cb479 --- /dev/null +++ b/test/PatternKit.Tests/Application/EventSourcing/EventStoreTests.cs @@ -0,0 +1,95 @@ +using PatternKit.Application.EventSourcing; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Application.EventSourcing; + +[Feature("Event Sourcing")] +public sealed partial class EventStoreTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Event store appends and reads stream events")] + [Fact] + public Task Event_Store_Appends_And_Reads_Stream_Events() + => Given("an order event store", () => InMemoryEventStore.Create("order-events").Build()) + .When("events are appended to one stream", (Func, ValueTask>)(async store => + { + var first = await store.AppendAsync("order-100", 0, [new OrderPlaced("order-100", 125m)]); + var second = await store.AppendAsync("order-100", 1, [new OrderPaid("order-100", "payment-1")]); + var stream = await store.ReadStreamAsync("order-100"); + return new(first, second, stream); + })) + .Then("the stream keeps committed order and versions", result => + { + ScenarioExpect.True(result.First.Committed); + ScenarioExpect.True(result.Second.Committed); + ScenarioExpect.Equal(1, result.First.AppendedCount); + ScenarioExpect.Equal(2, result.Second.Version); + ScenarioExpect.Equal([1L, 2L], result.Stream.Select(static stored => stored.Version)); + ScenarioExpect.IsType(result.Stream[0].Event); + ScenarioExpect.IsType(result.Stream[1].Event); + }) + .AssertPassed(); + + [Scenario("Event store rejects stale expected versions")] + [Fact] + public Task Event_Store_Rejects_Stale_Expected_Versions() + => Given("an order event stream with one committed event", (Func>>)(async () => + { + var store = InMemoryEventStore.Create("order-events").Build(); + _ = await store.AppendAsync("order-100", 0, [new OrderPlaced("order-100", 125m)]); + return store; + })) + .When("a stale append is attempted", (Func, ValueTask>)(async store => + { + var conflict = await store.AppendAsync("order-100", 0, [new OrderPaid("order-100", "payment-1")]); + var stream = await store.ReadStreamAsync("order-100"); + return new(conflict, stream); + })) + .Then("the append reports a conflict without mutating the stream", result => + { + ScenarioExpect.Equal(EventStoreAppendStatus.Conflict, result.Conflict.Status); + ScenarioExpect.False(result.Conflict.Committed); + ScenarioExpect.Equal(1, result.Conflict.Version); + ScenarioExpect.Equal(0, result.Conflict.ExpectedVersion); + ScenarioExpect.Single(result.Stream); + }) + .AssertPassed(); + + [Scenario("Event store validates required configuration")] + [Fact] + public Task Event_Store_Validates_Required_Configuration() + => Given("event store builders", () => true) + .Then("invalid arguments are rejected", _ => + { + ScenarioExpect.Throws(() => InMemoryEventStore.Create("")); + ScenarioExpect.Throws(() => InMemoryEventStore.Create("order-events").UseComparer(null!)); + var store = InMemoryEventStore.Create("order-events").Build(); + ScenarioExpect.Throws(() => store.AppendAsync(null!, 0, [new OrderPlaced("order-1", 10m)]).AsTask().GetAwaiter().GetResult()); + ScenarioExpect.Throws(() => store.AppendAsync("order-1", -1, [new OrderPlaced("order-1", 10m)]).AsTask().GetAwaiter().GetResult()); + ScenarioExpect.Throws(() => store.AppendAsync("order-1", 0, null!).AsTask().GetAwaiter().GetResult()); + ScenarioExpect.Throws(() => store.AppendAsync("order-1", 0, []).AsTask().GetAwaiter().GetResult()); + ScenarioExpect.Throws(() => store.AppendAsync("order-1", 0, [null!]).AsTask().GetAwaiter().GetResult()); + ScenarioExpect.Throws(() => store.ReadStreamAsync(null!).AsTask().GetAwaiter().GetResult()); + ScenarioExpect.Throws(() => EventStoreAppendResult.Commit(-1, 1)); + ScenarioExpect.Throws(() => EventStoreAppendResult.Commit(1, -1)); + ScenarioExpect.Throws(() => EventStoreAppendResult.Conflict(-1, 0)); + ScenarioExpect.Throws(() => EventStoreAppendResult.Conflict(0, -1)); + }) + .AssertPassed(); + + private abstract record OrderEvent(string OrderId); + + private sealed record OrderPlaced(string OrderId, decimal Total) : OrderEvent(OrderId); + + private sealed record OrderPaid(string OrderId, string PaymentId) : OrderEvent(OrderId); + + private sealed record StreamScenario( + EventStoreAppendResult First, + EventStoreAppendResult Second, + IReadOnlyList> Stream); + + private sealed record ConflictScenario( + EventStoreAppendResult Conflict, + IReadOnlyList> Stream); +}