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-materialized-view-pattern.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Order Materialized View Pattern

This production-shaped example builds an order read model from placed, paid, and shipped events.

It demonstrates:

- fluent `MaterializedView<OrderReadModel,OrderReadModelEvent>` construction
- generated materialized view factory with `[GenerateMaterializedView]`
- deterministic projection handler ordering
- singleton `IMaterializedView<OrderReadModel,OrderReadModelEvent>` registration through `IServiceCollection`

```csharp
var services = new ServiceCollection();
services.AddOrderMaterializedViewDemo();

using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();

var workflow = scope.ServiceProvider.GetRequiredService<OrderMaterializedViewWorkflow>();
var summary = await workflow.BuildReadModelAsync(events);
```

The registered view is singleton because it is stateless; importing applications can register consuming projection workflows with the lifetime that matches their event store, inbox, hosted consumer, tenant, or ASP.NET Core request services.

Files:

- `src/PatternKit.Examples/MaterializedViewDemo/OrderMaterializedViewDemo.cs`
- `test/PatternKit.Examples.Tests/MaterializedViewDemo/OrderMaterializedViewDemoTests.cs`
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@
- name: Order Audit Log Pattern
href: order-audit-log-pattern.md

- name: Order Materialized View Pattern
href: order-materialized-view-pattern.md

- name: Generated Mailbox
href: generated-mailbox.md

Expand Down
1 change: 1 addition & 0 deletions docs/generators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato
| [**Unit of Work**](unit-of-work.md) | Ordered commit and rollback units | `[GenerateUnitOfWork]` |
| [**Data Mapper**](data-mapper.md) | Domain/data model mapper factories | `[GenerateDataMapper]` |
| [**Identity Map**](identity-map.md) | Scoped object identity caches from key selectors | `[GenerateIdentityMap]` |
| [**Materialized View**](materialized-view.md) | Event projection read-model factories from handlers | `[GenerateMaterializedView]` |
| [**Transaction Script**](transaction-script.md) | Typed application workflow factories | `[GenerateTransactionScript]` |
| [**Service Layer**](service-layer.md) | Application operation boundary factories | `[GenerateServiceLayerOperation]` |
| [**Domain Event**](domain-event.md) | Domain event dispatcher factories | `[GenerateDomainEventDispatcher]` |
Expand Down
28 changes: 28 additions & 0 deletions docs/generators/materialized-view.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Materialized View Generator

`GenerateMaterializedViewAttribute` creates a typed `MaterializedView<TState,TEvent>` factory from annotated projection handler methods.

```csharp
[GenerateMaterializedView(typeof(OrderReadModel), typeof(OrderEvent), FactoryName = "CreateView", ViewName = "order-read-model")]
public static partial class GeneratedOrderMaterializedView
{
[MaterializedViewHandler(typeof(OrderPlaced), Order = 10)]
private static OrderReadModel ApplyPlaced(OrderReadModel state, OrderPlaced @event)
=> state with { OrderId = @event.OrderId };
}
```

The generated factory is equivalent to:

```csharp
MaterializedView<OrderReadModel, OrderEvent>
.Create("order-read-model")
.WithHandler<OrderPlaced>(ApplyPlaced, 10)
.Build();
```

Diagnostics:

- `PKMV001`: host type must be partial.
- `PKMV002`: at least one `[MaterializedViewHandler]` method is required.
- `PKMV003`: handler methods must be static and return the state type, or `ValueTask<TState>` with a `CancellationToken`.
3 changes: 3 additions & 0 deletions docs/generators/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@
- name: Identity Map
href: identity-map.md

- name: Materialized View
href: materialized-view.md

- name: Iterator
href: iterator.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 @@ -78,6 +78,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| Application Architecture | Event Sourcing | `IEventStore<TEvent,TStreamId>` and `InMemoryEventStore<TEvent,TStreamId>` | Event Sourcing generator |
| Application Architecture | Feature Toggle | `IFeatureToggleSet<TContext>` and `FeatureToggleSet<TContext>` | Feature Toggle generator |
| Application Architecture | Audit Log | `IAuditLog<TEntry,TKey>` and `InMemoryAuditLog<TEntry,TKey>` | Audit Log generator |
| Application Architecture | Materialized View | `IMaterializedView<TState,TEvent>` and `MaterializedView<TState,TEvent>` | Materialized View generator |
| Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer<TExternal, TDomain>` | Anti-Corruption Layer generator |

## Research Baselines
Expand Down
24 changes: 24 additions & 0 deletions docs/patterns/application/materialized-view.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Materialized View

Materialized View builds a query-optimized read model by replaying domain or integration events. Use it when command-side state is not shaped for screens, reports, APIs, or search endpoints.

PatternKit provides `IMaterializedView<TState,TEvent>` and `MaterializedView<TState,TEvent>` in `PatternKit.Application.MaterializedViews`.

```csharp
var view = MaterializedView<OrderReadModel, OrderEvent>
.Create("order-read-model")
.WithHandler<OrderPlaced>((state, e) => state with { OrderId = e.OrderId })
.WithHandler<OrderPaid>((state, _) => state with { Status = "Paid" })
.Build();

var projected = await view.ProjectAsync(OrderReadModel.Empty("order-read-model"), events);
```

Handlers are ordered, deterministic, cancellation-aware, and can be synchronous or asynchronous. Register `IMaterializedView<TState,TEvent>` through `IServiceCollection` when projection workflows need to compose with event stores, inboxes, hosted services, or ASP.NET Core request scopes.

Use the source-generated path when the read model and event handlers are stable.

See also:

- [Materialized View generator](../../generators/materialized-view.md)
- [Order Materialized View example](../../examples/order-materialized-view-pattern.md)
2 changes: 2 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,8 @@
href: application/feature-toggle.md
- name: Audit Log
href: application/audit-log.md
- name: Materialized View
href: application/materialized-view.md
- name: Specification
href: application/specification.md
- name: Type-Dispatcher
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
namespace PatternKit.Application.MaterializedViews;

public interface IMaterializedView<TState, TEvent>
{
string Name { get; }

ValueTask<TState> ProjectAsync(TState initialState, IEnumerable<TEvent> events, CancellationToken cancellationToken = default);
}

public sealed class MaterializedView<TState, TEvent> : IMaterializedView<TState, TEvent>
{
private readonly HandlerRegistration[] _handlers;

private MaterializedView(string name, IEnumerable<HandlerRegistration> handlers)
{
Name = name;
_handlers = handlers
.OrderBy(static handler => handler.Order)
.ThenBy(static handler => handler.Index)
.ToArray();
}

public string Name { get; }

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

public async ValueTask<TState> ProjectAsync(TState initialState, IEnumerable<TEvent> events, CancellationToken cancellationToken = default)
{
if (events is null)
throw new ArgumentNullException(nameof(events));

var state = initialState;
foreach (var @event in events)
{
cancellationToken.ThrowIfCancellationRequested();
if (@event is null)
throw new ArgumentException("Materialized view event stream cannot contain null events.", nameof(events));

var eventType = @event.GetType();
foreach (var handler in _handlers)
{
if (!handler.EventType.IsAssignableFrom(eventType))
continue;

state = await handler.ApplyAsync(state, @event, cancellationToken).ConfigureAwait(false);
}
}

return state;
}

public sealed class Builder
{
private readonly List<HandlerRegistration> _handlers = new();
private readonly string _name;

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

public Builder WithHandler<TSpecificEvent>(Func<TState, TSpecificEvent, TState> handler, int order = 0)
where TSpecificEvent : TEvent
{
if (handler is null)
throw new ArgumentNullException(nameof(handler));

_handlers.Add(new HandlerRegistration(
typeof(TSpecificEvent),
order,
_handlers.Count,
(state, @event, _) => @event is TSpecificEvent specificEvent
? new ValueTask<TState>(handler(state, specificEvent))
: throw new InvalidOperationException("Materialized view handler received an incompatible event.")));
return this;
}

public Builder WithAsyncHandler<TSpecificEvent>(
Func<TState, TSpecificEvent, CancellationToken, ValueTask<TState>> handler,
int order = 0)
where TSpecificEvent : TEvent
{
if (handler is null)
throw new ArgumentNullException(nameof(handler));

_handlers.Add(new HandlerRegistration(
typeof(TSpecificEvent),
order,
_handlers.Count,
(state, @event, cancellationToken) => @event is TSpecificEvent specificEvent
? handler(state, specificEvent, cancellationToken)
: throw new InvalidOperationException("Materialized view handler received an incompatible event.")));
return this;
}

public MaterializedView<TState, TEvent> Build()
{
if (_handlers.Count == 0)
throw new InvalidOperationException("Materialized view requires at least one event handler.");

return new MaterializedView<TState, TEvent>(_name, _handlers);
}
}

private sealed class HandlerRegistration
{
public HandlerRegistration(
Type eventType,
int order,
int index,
Func<TState, TEvent, CancellationToken, ValueTask<TState>> applyAsync)
{
EventType = eventType;
Order = order;
Index = index;
ApplyAsync = applyAsync;
}

public Type EventType { get; }

public int Order { get; }

public int Index { get; }

public Func<TState, TEvent, CancellationToken, ValueTask<TState>> ApplyAsync { get; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
using PatternKit.Examples.Generators.Builders.CorporateApplicationBuilderDemo;
using PatternKit.Examples.Generators.Visitors;
using PatternKit.Examples.IdentityMapDemo;
using PatternKit.Examples.MaterializedViewDemo;
using PatternKit.Examples.MementoDemo;
using PatternKit.Examples.Messaging;
using PatternKit.Examples.ObserverDemo;
Expand Down Expand Up @@ -142,6 +143,7 @@ public sealed record OrderTableDataGatewayPatternExample(OrderTableDataGatewayDe
public sealed record OrderEventSourcingPatternExample(OrderEventSourcingDemoRunner Runner);
public sealed record CheckoutFeatureTogglePatternExample(CheckoutFeatureToggleDemoRunner Runner);
public sealed record OrderAuditLogPatternExample(OrderAuditLogDemoRunner Runner);
public sealed record OrderMaterializedViewPatternExample(OrderMaterializedViewDemoRunner Runner, OrderMaterializedViewWorkflow Workflow);
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 @@ -209,6 +211,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s
.AddOrderEventSourcingPatternExample()
.AddCheckoutFeatureTogglePatternExample()
.AddOrderAuditLogPatternExample()
.AddOrderMaterializedViewPatternExample()
.AddPrototypeGameCharacterFactoryExample()
.AddProxyPatternDemonstrationsExample()
.AddFlyweightGlyphCacheExample()
Expand Down Expand Up @@ -624,6 +627,15 @@ public static IServiceCollection AddOrderAuditLogPatternExample(this IServiceCol
return services.RegisterExample<OrderAuditLogPatternExample>("Order Audit Log Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost);
}

public static IServiceCollection AddOrderMaterializedViewPatternExample(this IServiceCollection services)
{
services.AddOrderMaterializedViewDemo();
services.AddSingleton<OrderMaterializedViewPatternExample>(sp => new(
sp.GetRequiredService<OrderMaterializedViewDemoRunner>(),
sp.GetRequiredService<OrderMaterializedViewWorkflow>()));
return services.RegisterExample<OrderMaterializedViewPatternExample>("Order Materialized View 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