Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/examples/order-event-sourcing-pattern.md
Original file line number Diff line number Diff line change
@@ -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<OrderEvent,string>` construction
- generated event store factory with `[GenerateEventStore]`
- optimistic concurrency through expected stream versions
- scoped `IEventStore<OrderEvent,string>` 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<OrderEventSourcingWorkflow>();
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`
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions docs/generators/event-sourcing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Event Sourcing Generator

`GenerateEventStoreAttribute` creates a typed `InMemoryEventStore<TEvent,TStreamId>` 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<OrderEvent, string>
.Create("order-events")
.Build();
```

Diagnostics:

- `PKES001`: host type must be partial.
1 change: 1 addition & 0 deletions docs/generators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]` |

Expand Down
3 changes: 3 additions & 0 deletions docs/generators/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@
- name: Domain Event
href: domain-event.md

- name: Event Sourcing
href: event-sourcing.md

- name: Dispatcher
href: dispatcher.md

Expand Down
1 change: 1 addition & 0 deletions docs/guides/pattern-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| Application Architecture | Service Layer | `IServiceOperation<TRequest,TResponse>` and `ServiceLayerOperation<TRequest,TResponse>` | Service Layer generator |
| Application Architecture | Domain Event | `IDomainEvent` and `DomainEventDispatcher<TEventBase>` | Domain Event generator |
| Application Architecture | Table Data Gateway | `ITableDataGateway<TRow,TKey>` and `InMemoryTableDataGateway<TRow,TKey>` | Table Data Gateway generator |
| Application Architecture | Event Sourcing | `IEventStore<TEvent,TStreamId>` and `InMemoryEventStore<TEvent,TStreamId>` | Event Sourcing generator |
| Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer<TExternal, TDomain>` | Anti-Corruption Layer generator |

## Research Baselines
Expand Down
28 changes: 28 additions & 0 deletions docs/patterns/application/event-sourcing.md
Original file line number Diff line number Diff line change
@@ -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<TEvent,TStreamId>` and `InMemoryEventStore<TEvent,TStreamId>` in `PatternKit.Application.EventSourcing`.

```csharp
var store = InMemoryEventStore<OrderEvent, string>
.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<TEvent,TStreamId>` 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)
2 changes: 2 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
169 changes: 169 additions & 0 deletions src/PatternKit.Core/Application/EventSourcing/EventStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
namespace PatternKit.Application.EventSourcing;

public interface IEventStore<TEvent, TStreamId>
where TStreamId : notnull
{
string Name { get; }

ValueTask<EventStoreAppendResult> AppendAsync(TStreamId streamId, long expectedVersion, IEnumerable<TEvent> events, CancellationToken cancellationToken = default);

ValueTask<IReadOnlyList<StoredEvent<TEvent, TStreamId>>> ReadStreamAsync(TStreamId streamId, CancellationToken cancellationToken = default);
}

public sealed class InMemoryEventStore<TEvent, TStreamId> : IEventStore<TEvent, TStreamId>
where TStreamId : notnull
{
private readonly object _gate = new();
private readonly Dictionary<TStreamId, List<StoredEvent<TEvent, TStreamId>>> _streams;

private InMemoryEventStore(string name, IEqualityComparer<TStreamId>? comparer)
{
Name = name;
_streams = new Dictionary<TStreamId, List<StoredEvent<TEvent, TStreamId>>>(comparer);
}

public string Name { get; }

public static Builder Create(string name) => new(name);

public ValueTask<EventStoreAppendResult> AppendAsync(TStreamId streamId, long expectedVersion, IEnumerable<TEvent> 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));

Comment on lines +44 to +50
if (stream is null)
{
stream = new List<StoredEvent<TEvent, TStreamId>>();
_streams[streamId] = stream;
}

var appended = 0;
foreach (var @event in pending)
{
appended++;
stream.Add(new StoredEvent<TEvent, TStreamId>(streamId, currentVersion + appended, @event, DateTimeOffset.UtcNow));
}

return new(EventStoreAppendResult.Commit(currentVersion + appended, appended));
}
}

public ValueTask<IReadOnlyList<StoredEvent<TEvent, TStreamId>>> 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<StoredEvent<TEvent, TStreamId>>());
}
}

public sealed class Builder
{
private readonly string _name;
private IEqualityComparer<TStreamId>? _comparer;

internal Builder(string name)
{
_name = string.IsNullOrWhiteSpace(name)
? throw new ArgumentException("Event store name is required.", nameof(name))
: name;
}

public Builder UseComparer(IEqualityComparer<TStreamId> comparer)
{
_comparer = comparer ?? throw new ArgumentNullException(nameof(comparer));
return this;
}

public InMemoryEventStore<TEvent, TStreamId> Build() => new(_name, _comparer);
}
}

public sealed class StoredEvent<TEvent, TStreamId>
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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, PrototypeDemo.PrototypeDemo.GameCharacter> Factory);
public sealed record ProxyPatternDemonstrationsExample(Proxy<int, string> RemoteProxy, Proxy<(string To, string Subject, string Body), bool> EmailProxy);
public sealed record FlyweightGlyphCacheExample(Func<string, IReadOnlyList<(FlyweightDemo.FlyweightDemo.Glyph Glyph, int X)>> RenderSentence);
Expand Down Expand Up @@ -200,6 +202,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s
.AddCustomerServiceLayerPatternExample()
.AddOrderDomainEventPatternExample()
.AddOrderTableDataGatewayPatternExample()
.AddOrderEventSourcingPatternExample()
.AddPrototypeGameCharacterFactoryExample()
.AddProxyPatternDemonstrationsExample()
.AddFlyweightGlyphCacheExample()
Expand Down Expand Up @@ -594,6 +597,13 @@ public static IServiceCollection AddOrderTableDataGatewayPatternExample(this ISe
return services.RegisterExample<OrderTableDataGatewayPatternExample>("Order Table Data Gateway Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost);
}

public static IServiceCollection AddOrderEventSourcingPatternExample(this IServiceCollection services)
{
services.AddOrderEventSourcingDemo();
services.AddSingleton<OrderEventSourcingPatternExample>(sp => new(sp.GetRequiredService<OrderEventSourcingDemoRunner>()));
return services.RegisterExample<OrderEventSourcingPatternExample>("Order Event Sourcing Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost);
}

public static IServiceCollection AddPrototypeGameCharacterFactoryExample(this IServiceCollection services)
{
services.AddSingleton(_ => PrototypeDemo.PrototypeDemo.CreateCharacterFactory());
Expand Down
Loading
Loading