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
24 changes: 18 additions & 6 deletions EventSourcing.NetCore.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -251,9 +251,15 @@
<Project Path="Workshops/IntroductionToEventSourcing/12-Projections.SingleStream/12-Projections.SingleStream.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/13-Projections.SingleStream.Idempotency/13-Projections.SingleStream.Idempotency.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/14-Projections.SingleStream.EventualConsistency/14-Projections.SingleStream.EventualConsistency.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/15-EventsDefinition/15-EventsDefinition.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/16-EntitiesDefinition/16-EntitiesDefinition.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/17-BusinessProcesses/17-BusinessProcesses.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/15-Projections.MultiStream/15-Projections.MultiStream.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/16-Projections.MultiStream.OutOfOrder/16-Projections.MultiStream.OutOfOrder.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/17-Projections.MultiStream.Marten/17-Projections.MultiStream.Marten.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/18-Projections.MultiStream.OutOfOrder.Marten/18-Projections.MultiStream.OutOfOrder.Marten.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/19-EventsDefinition/19-EventsDefinition.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/20-EntitiesDefinition/20-EntitiesDefinition.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/21-BusinessProcesses/21-BusinessProcesses.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/22-Idempotency/22-Idempotency.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/23-Consistency/23-Consistency.csproj" />
</Folder>
<Folder Name="/Workshops/IntroductionToEventSourcing/docker/">
<File Path="Workshops/IntroductionToEventSourcing/docker-compose.yml" />
Expand All @@ -278,9 +284,15 @@
<Project Path="Workshops/IntroductionToEventSourcing/Solved/12-Projections.SingleStream/12-Projections.SingleStream.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/Solved/13-Projections.SingleStream.Idempotency/13-Projections.SingleStream.Idempotency.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/Solved/14-Projections.SingleStream.EventualConsistency/14-Projections.SingleStream.EventualConsistency.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/Solved/15-EventsDefinition/15-EventsDefinition.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/Solved/16-EntitiesDefinition/16-EntitiesDefinition.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/Solved/17-BusinessProcesses/17-BusinessProcesses.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/Solved/15-Projections.MultiStream/15-Projections.MultiStream.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/Solved/16-Projections.MultiStream.OutOfOrder/16-Projections.MultiStream.OutOfOrder.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/Solved/17-Projections.MultiStream.Marten/17-Projections.MultiStream.Marten.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/Solved/18-Projections.MultiStream.OutOfOrder.Marten/18-Projections.MultiStream.OutOfOrder.Marten.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/Solved/19-EventsDefinition/19-EventsDefinition.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/Solved/20-EntitiesDefinition/20-EntitiesDefinition.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/Solved/21-BusinessProcesses/21-BusinessProcesses.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/Solved/22-Idempotency/22-Idempotency.csproj" />
<Project Path="Workshops/IntroductionToEventSourcing/Solved/23-Consistency/23-Consistency.csproj" />
</Folder>
<Properties Name="RiderSharedRunConfigurations" Scope="PostLoad" />
</Solution>
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>IntroductionToEventSourcing.Projections.MultiStream</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="GitHubActionsTestLogger">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using FluentAssertions;
using IntroductionToEventSourcing.Projections.MultiStream.Tools;
using Xunit;

namespace IntroductionToEventSourcing.Projections.MultiStream;

// EVENTS
public record PaymentRecorded(
Guid PaymentId,
Guid OrderId,
decimal Amount
);

public record MerchantLimitsChecked(
Guid PaymentId,
Guid MerchantId,
bool IsWithinLimits
);

public record FraudScoreCalculated(
Guid PaymentId,
decimal Score,
bool IsAcceptable
);

public record PaymentVerificationCompleted(
Guid PaymentId,
bool IsApproved
);

// ENUMS
public enum VerificationStatus
{
Pending,
Passed,
Failed
}

public enum PaymentStatus
{
Pending,
Approved,
Rejected
}

// READ MODEL
public class PaymentVerification
{
public Guid Id { get; set; }
public PaymentStatus Status { get; set; }
}

public class ProjectionsTests
{
[Fact]
[Trait("Category", "SkipCI")]
public void MultiStreamProjection_ForPaymentVerification_ShouldSucceed()
{
var payment1Id = Guid.CreateVersion7();
var payment2Id = Guid.CreateVersion7();
var payment3Id = Guid.CreateVersion7();
var payment4Id = Guid.CreateVersion7();

var order1Id = Guid.CreateVersion7();
var order2Id = Guid.CreateVersion7();
var order3Id = Guid.CreateVersion7();
var order4Id = Guid.CreateVersion7();

var merchant1Id = Guid.CreateVersion7();
var merchant2Id = Guid.CreateVersion7();

var fraudCheck1Id = Guid.CreateVersion7();
var fraudCheck2Id = Guid.CreateVersion7();
var fraudCheck3Id = Guid.CreateVersion7();

var eventStore = new EventStore();
var database = new Database();

// TODO:
// 1. Create a PaymentVerificationProjection class that handles each event type.
// Events arrive on different streams (payment, merchant, fraud check),
// but they share PaymentId — use PaymentId as the read model key.
// 2. Register your event handlers using `eventStore.Register`.

// Payment 1: Approved — all checks pass
eventStore.Append(payment1Id, new PaymentRecorded(payment1Id, order1Id, 100m));
eventStore.Append(merchant1Id, new MerchantLimitsChecked(payment1Id, merchant1Id, true));
eventStore.Append(fraudCheck1Id, new FraudScoreCalculated(payment1Id, 0.1m, true));
eventStore.Append(payment1Id, new PaymentVerificationCompleted(payment1Id, true));

// Payment 2: Merchant rejected — exceeds merchant limits
eventStore.Append(payment2Id, new PaymentRecorded(payment2Id, order2Id, 5000m));
eventStore.Append(merchant2Id, new MerchantLimitsChecked(payment2Id, merchant2Id, false));
eventStore.Append(fraudCheck2Id, new FraudScoreCalculated(payment2Id, 0.2m, true));
eventStore.Append(payment2Id, new PaymentVerificationCompleted(payment2Id, false));

// Payment 3: Fraud rejected — high fraud score
eventStore.Append(payment3Id, new PaymentRecorded(payment3Id, order3Id, 200m));
eventStore.Append(merchant1Id, new MerchantLimitsChecked(payment3Id, merchant1Id, true));
eventStore.Append(fraudCheck3Id, new FraudScoreCalculated(payment3Id, 0.95m, false));
eventStore.Append(payment3Id, new PaymentVerificationCompleted(payment3Id, false));

// Payment 4: Pending — still awaiting fraud check and final decision
eventStore.Append(payment4Id, new PaymentRecorded(payment4Id, order4Id, 50m));
eventStore.Append(merchant1Id, new MerchantLimitsChecked(payment4Id, merchant1Id, true));

// Assert Payment 1: Approved
var payment1 = database.Get<PaymentVerification>(payment1Id)!;
payment1.Should().NotBeNull();
payment1.Id.Should().Be(payment1Id);
payment1.Status.Should().Be(PaymentStatus.Approved);

// Assert Payment 2: Merchant rejected
var payment2 = database.Get<PaymentVerification>(payment2Id)!;
payment2.Should().NotBeNull();
payment2.Id.Should().Be(payment2Id);
payment2.Status.Should().Be(PaymentStatus.Rejected);

// Assert Payment 3: Fraud rejected
var payment3 = database.Get<PaymentVerification>(payment3Id)!;
payment3.Should().NotBeNull();
payment3.Id.Should().Be(payment3Id);
payment3.Status.Should().Be(PaymentStatus.Rejected);

// Assert Payment 4: Pending
var payment4 = database.Get<PaymentVerification>(payment4Id)!;
payment4.Should().NotBeNull();
payment4.Id.Should().Be(payment4Id);
payment4.Status.Should().Be(PaymentStatus.Pending);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Exercise 15 - Multi-Stream Projections

In exercises 12-14 you built projections from a single event stream. Now you'll combine events from **multiple streams** into a single read model.

## Scenario: Payment Verification

A payment verification requires data from three independent checks, each producing events on its own stream:

1. **Payment recorded** — from the payment service (amount, order reference)
2. **Merchant limits checked** — from the merchant service (within daily limits?)
3. **Fraud score calculated** — from the fraud detection service (risk score, acceptable?)
4. **Verification completed** — final decision event (approved or rejected)

All events share a `PaymentId` that ties them to the same payment verification read model.

## What to implement

With the [Database](./Tools/Database.cs) interface representing the sample database, implement a `PaymentVerification` read model and projection:

1. Define the `PaymentVerification` read model properties — the test assertions tell you what shape it needs.
2. Create a `PaymentVerificationProjection` class with typed `Handle` methods for each event.
3. Register handlers in the test using `eventStore.Register`.

The key difference from single-stream projections: each event arrives on a **different stream ID**, but they all reference the same `PaymentId`. Your projection must use `PaymentId` (not the stream ID) as the read model key.

## Reference

Read more about multi-stream projections and handling events from multiple sources:
- [Handling Events Coming in an Unknown Order](https://www.architecture-weekly.com/p/handling-events-coming-in-an-unknown)
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Text.Json;

namespace IntroductionToEventSourcing.Projections.MultiStream.Tools;

public class Database
{
private readonly Dictionary<string, object> storage = new();

public void Store<T>(Guid id, T obj) where T: class =>
storage[GetId<T>(id)] = obj;

public void Delete<T>(Guid id) =>
storage.Remove(GetId<T>(id));

public T? Get<T>(Guid id) where T: class =>
storage.TryGetValue(GetId<T>(id), out var result) ?
// Clone to simulate getting new instance on loading
JsonSerializer.Deserialize<T>(JsonSerializer.Serialize((T)result))
: null;

private static string GetId<T>(Guid id) => $"{typeof(T).Name}-{id}";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace IntroductionToEventSourcing.Projections.MultiStream.Tools;

public record EventMetadata(
string EventId,
ulong StreamPosition,
ulong LogPosition
)
{
public static EventMetadata For(ulong streamPosition, ulong logPosition) =>
new(Guid.CreateVersion7().ToString(), streamPosition, logPosition);
}

public record EventEnvelope(
object Data,
EventMetadata Metadata
);

public record EventEnvelope<T>(
T Data,
EventMetadata Metadata
): EventEnvelope(Data, Metadata) where T : notnull
{
public new T Data => (T)base.Data;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
namespace IntroductionToEventSourcing.Projections.MultiStream.Tools;

public class EventStore
{
private readonly Dictionary<Type, List<Action<EventEnvelope>>> handlers = new();
private readonly Dictionary<Guid, List<EventEnvelope>> events = new();

public void Register<TEvent>(Action<EventEnvelope<TEvent>> handler) where TEvent : notnull
{
var eventType = typeof(TEvent);

void WrappedHandler(object @event) => handler((EventEnvelope<TEvent>)@event);

if (handlers.TryGetValue(eventType, out var handler1))
handler1.Add(WrappedHandler);
else
handlers.Add(eventType, [WrappedHandler]);
}

public void Append<TEvent>(Guid streamId, TEvent @event) where TEvent : notnull
{
if (!events.ContainsKey(streamId))
events[streamId] = [];

var eventEnvelope = new EventEnvelope<TEvent>(@event,
EventMetadata.For(
(ulong)events[streamId].Count + 1,
(ulong)events.Values.Sum(s => s.Count)
)
);

events[streamId].Add(eventEnvelope);

if (!handlers.TryGetValue(eventEnvelope.Data.GetType(), out var eventHandlers)) return;

foreach (var handle in eventHandlers)
{
handle(eventEnvelope);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>IntroductionToEventSourcing.Projections.MultiStream.OutOfOrder</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="GitHubActionsTestLogger">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

</Project>
Loading