Skip to content

Commit f630fd9

Browse files
committed
Added Multi stream projection exercise to introduction to Event Sourcing workshop
Renamed also the further exercises
1 parent 1b536c5 commit f630fd9

File tree

243 files changed

+2335
-18
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

243 files changed

+2335
-18
lines changed

.claude/settings.local.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(ls:*)"
5+
]
6+
}
7+
}

EventSourcing.NetCore.slnx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -251,9 +251,15 @@
251251
<Project Path="Workshops/IntroductionToEventSourcing/12-Projections.SingleStream/12-Projections.SingleStream.csproj" />
252252
<Project Path="Workshops/IntroductionToEventSourcing/13-Projections.SingleStream.Idempotency/13-Projections.SingleStream.Idempotency.csproj" />
253253
<Project Path="Workshops/IntroductionToEventSourcing/14-Projections.SingleStream.EventualConsistency/14-Projections.SingleStream.EventualConsistency.csproj" />
254-
<Project Path="Workshops/IntroductionToEventSourcing/15-EventsDefinition/15-EventsDefinition.csproj" />
255-
<Project Path="Workshops/IntroductionToEventSourcing/16-EntitiesDefinition/16-EntitiesDefinition.csproj" />
256-
<Project Path="Workshops/IntroductionToEventSourcing/17-BusinessProcesses/17-BusinessProcesses.csproj" />
254+
<Project Path="Workshops/IntroductionToEventSourcing/15-Projections.MultiStream/15-Projections.MultiStream.csproj" />
255+
<Project Path="Workshops/IntroductionToEventSourcing/16-Projections.MultiStream.OutOfOrder/16-Projections.MultiStream.OutOfOrder.csproj" />
256+
<Project Path="Workshops/IntroductionToEventSourcing/17-Projections.MultiStream.Marten/17-Projections.MultiStream.Marten.csproj" />
257+
<Project Path="Workshops/IntroductionToEventSourcing/18-Projections.MultiStream.OutOfOrder.Marten/18-Projections.MultiStream.OutOfOrder.Marten.csproj" />
258+
<Project Path="Workshops/IntroductionToEventSourcing/19-EventsDefinition/19-EventsDefinition.csproj" />
259+
<Project Path="Workshops/IntroductionToEventSourcing/20-EntitiesDefinition/20-EntitiesDefinition.csproj" />
260+
<Project Path="Workshops/IntroductionToEventSourcing/21-BusinessProcesses/21-BusinessProcesses.csproj" />
261+
<Project Path="Workshops/IntroductionToEventSourcing/22-Idempotency/22-Idempotency.csproj" />
262+
<Project Path="Workshops/IntroductionToEventSourcing/23-Consistency/23-Consistency.csproj" />
257263
</Folder>
258264
<Folder Name="/Workshops/IntroductionToEventSourcing/docker/">
259265
<File Path="Workshops/IntroductionToEventSourcing/docker-compose.yml" />
@@ -278,9 +284,15 @@
278284
<Project Path="Workshops/IntroductionToEventSourcing/Solved/12-Projections.SingleStream/12-Projections.SingleStream.csproj" />
279285
<Project Path="Workshops/IntroductionToEventSourcing/Solved/13-Projections.SingleStream.Idempotency/13-Projections.SingleStream.Idempotency.csproj" />
280286
<Project Path="Workshops/IntroductionToEventSourcing/Solved/14-Projections.SingleStream.EventualConsistency/14-Projections.SingleStream.EventualConsistency.csproj" />
281-
<Project Path="Workshops/IntroductionToEventSourcing/Solved/15-EventsDefinition/15-EventsDefinition.csproj" />
282-
<Project Path="Workshops/IntroductionToEventSourcing/Solved/16-EntitiesDefinition/16-EntitiesDefinition.csproj" />
283-
<Project Path="Workshops/IntroductionToEventSourcing/Solved/17-BusinessProcesses/17-BusinessProcesses.csproj" />
287+
<Project Path="Workshops/IntroductionToEventSourcing/Solved/15-Projections.MultiStream/15-Projections.MultiStream.csproj" />
288+
<Project Path="Workshops/IntroductionToEventSourcing/Solved/16-Projections.MultiStream.OutOfOrder/16-Projections.MultiStream.OutOfOrder.csproj" />
289+
<Project Path="Workshops/IntroductionToEventSourcing/Solved/17-Projections.MultiStream.Marten/17-Projections.MultiStream.Marten.csproj" />
290+
<Project Path="Workshops/IntroductionToEventSourcing/Solved/18-Projections.MultiStream.OutOfOrder.Marten/18-Projections.MultiStream.OutOfOrder.Marten.csproj" />
291+
<Project Path="Workshops/IntroductionToEventSourcing/Solved/19-EventsDefinition/19-EventsDefinition.csproj" />
292+
<Project Path="Workshops/IntroductionToEventSourcing/Solved/20-EntitiesDefinition/20-EntitiesDefinition.csproj" />
293+
<Project Path="Workshops/IntroductionToEventSourcing/Solved/21-BusinessProcesses/21-BusinessProcesses.csproj" />
294+
<Project Path="Workshops/IntroductionToEventSourcing/Solved/22-Idempotency/22-Idempotency.csproj" />
295+
<Project Path="Workshops/IntroductionToEventSourcing/Solved/23-Consistency/23-Consistency.csproj" />
284296
</Folder>
285297
<Properties Name="RiderSharedRunConfigurations" Scope="PostLoad" />
286298
</Solution>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<RootNamespace>IntroductionToEventSourcing.Projections.MultiStream</RootNamespace>
6+
</PropertyGroup>
7+
8+
<ItemGroup>
9+
<PackageReference Include="FluentAssertions" />
10+
<PackageReference Include="Microsoft.NET.Test.Sdk" />
11+
<PackageReference Include="GitHubActionsTestLogger">
12+
<PrivateAssets>all</PrivateAssets>
13+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
14+
</PackageReference>
15+
<PackageReference Include="xunit" />
16+
<PackageReference Include="xunit.runner.visualstudio">
17+
<PrivateAssets>all</PrivateAssets>
18+
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
19+
</PackageReference>
20+
<PackageReference Include="coverlet.collector">
21+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
22+
<PrivateAssets>all</PrivateAssets>
23+
</PackageReference>
24+
</ItemGroup>
25+
26+
</Project>
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
using FluentAssertions;
2+
using IntroductionToEventSourcing.Projections.MultiStream.Tools;
3+
using Xunit;
4+
5+
namespace IntroductionToEventSourcing.Projections.MultiStream;
6+
7+
// EVENTS
8+
public record PaymentRecorded(
9+
Guid PaymentId,
10+
Guid OrderId,
11+
decimal Amount
12+
);
13+
14+
public record MerchantLimitsChecked(
15+
Guid PaymentId,
16+
Guid MerchantId,
17+
bool IsWithinLimits
18+
);
19+
20+
public record FraudScoreCalculated(
21+
Guid PaymentId,
22+
decimal Score,
23+
bool IsAcceptable
24+
);
25+
26+
public record PaymentVerificationCompleted(
27+
Guid PaymentId,
28+
bool IsApproved
29+
);
30+
31+
// ENUMS
32+
public enum VerificationStatus
33+
{
34+
Pending,
35+
Passed,
36+
Failed
37+
}
38+
39+
public enum PaymentStatus
40+
{
41+
Pending,
42+
Approved,
43+
Rejected
44+
}
45+
46+
// READ MODEL
47+
public class PaymentVerification
48+
{
49+
public Guid Id { get; set; }
50+
public PaymentStatus Status { get; set; }
51+
}
52+
53+
public class ProjectionsTests
54+
{
55+
[Fact]
56+
[Trait("Category", "SkipCI")]
57+
public void MultiStreamProjection_ForPaymentVerification_ShouldSucceed()
58+
{
59+
var payment1Id = Guid.CreateVersion7();
60+
var payment2Id = Guid.CreateVersion7();
61+
var payment3Id = Guid.CreateVersion7();
62+
var payment4Id = Guid.CreateVersion7();
63+
64+
var order1Id = Guid.CreateVersion7();
65+
var order2Id = Guid.CreateVersion7();
66+
var order3Id = Guid.CreateVersion7();
67+
var order4Id = Guid.CreateVersion7();
68+
69+
var merchant1Id = Guid.CreateVersion7();
70+
var merchant2Id = Guid.CreateVersion7();
71+
72+
var fraudCheck1Id = Guid.CreateVersion7();
73+
var fraudCheck2Id = Guid.CreateVersion7();
74+
var fraudCheck3Id = Guid.CreateVersion7();
75+
76+
var eventStore = new EventStore();
77+
var database = new Database();
78+
79+
// TODO:
80+
// 1. Create a PaymentVerificationProjection class that handles each event type.
81+
// Events arrive on different streams (payment, merchant, fraud check),
82+
// but they share PaymentId — use PaymentId as the read model key.
83+
// 2. Register your event handlers using `eventStore.Register`.
84+
85+
// Payment 1: Approved — all checks pass
86+
eventStore.Append(payment1Id, new PaymentRecorded(payment1Id, order1Id, 100m));
87+
eventStore.Append(merchant1Id, new MerchantLimitsChecked(payment1Id, merchant1Id, true));
88+
eventStore.Append(fraudCheck1Id, new FraudScoreCalculated(payment1Id, 0.1m, true));
89+
eventStore.Append(payment1Id, new PaymentVerificationCompleted(payment1Id, true));
90+
91+
// Payment 2: Merchant rejected — exceeds merchant limits
92+
eventStore.Append(payment2Id, new PaymentRecorded(payment2Id, order2Id, 5000m));
93+
eventStore.Append(merchant2Id, new MerchantLimitsChecked(payment2Id, merchant2Id, false));
94+
eventStore.Append(fraudCheck2Id, new FraudScoreCalculated(payment2Id, 0.2m, true));
95+
eventStore.Append(payment2Id, new PaymentVerificationCompleted(payment2Id, false));
96+
97+
// Payment 3: Fraud rejected — high fraud score
98+
eventStore.Append(payment3Id, new PaymentRecorded(payment3Id, order3Id, 200m));
99+
eventStore.Append(merchant1Id, new MerchantLimitsChecked(payment3Id, merchant1Id, true));
100+
eventStore.Append(fraudCheck3Id, new FraudScoreCalculated(payment3Id, 0.95m, false));
101+
eventStore.Append(payment3Id, new PaymentVerificationCompleted(payment3Id, false));
102+
103+
// Payment 4: Pending — still awaiting fraud check and final decision
104+
eventStore.Append(payment4Id, new PaymentRecorded(payment4Id, order4Id, 50m));
105+
eventStore.Append(merchant1Id, new MerchantLimitsChecked(payment4Id, merchant1Id, true));
106+
107+
// Assert Payment 1: Approved
108+
var payment1 = database.Get<PaymentVerification>(payment1Id)!;
109+
payment1.Should().NotBeNull();
110+
payment1.Id.Should().Be(payment1Id);
111+
payment1.Status.Should().Be(PaymentStatus.Approved);
112+
113+
// Assert Payment 2: Merchant rejected
114+
var payment2 = database.Get<PaymentVerification>(payment2Id)!;
115+
payment2.Should().NotBeNull();
116+
payment2.Id.Should().Be(payment2Id);
117+
payment2.Status.Should().Be(PaymentStatus.Rejected);
118+
119+
// Assert Payment 3: Fraud rejected
120+
var payment3 = database.Get<PaymentVerification>(payment3Id)!;
121+
payment3.Should().NotBeNull();
122+
payment3.Id.Should().Be(payment3Id);
123+
payment3.Status.Should().Be(PaymentStatus.Rejected);
124+
125+
// Assert Payment 4: Pending
126+
var payment4 = database.Get<PaymentVerification>(payment4Id)!;
127+
payment4.Should().NotBeNull();
128+
payment4.Id.Should().Be(payment4Id);
129+
payment4.Status.Should().Be(PaymentStatus.Pending);
130+
}
131+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Exercise 15 - Multi-Stream Projections
2+
3+
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.
4+
5+
## Scenario: Payment Verification
6+
7+
A payment verification requires data from three independent checks, each producing events on its own stream:
8+
9+
1. **Payment recorded** — from the payment service (amount, order reference)
10+
2. **Merchant limits checked** — from the merchant service (within daily limits?)
11+
3. **Fraud score calculated** — from the fraud detection service (risk score, acceptable?)
12+
4. **Verification completed** — final decision event (approved or rejected)
13+
14+
All events share a `PaymentId` that ties them to the same payment verification read model.
15+
16+
## What to implement
17+
18+
With the [Database](./Tools/Database.cs) interface representing the sample database, implement a `PaymentVerification` read model and projection:
19+
20+
1. Define the `PaymentVerification` read model properties — the test assertions tell you what shape it needs.
21+
2. Create a `PaymentVerificationProjection` class with typed `Handle` methods for each event.
22+
3. Register handlers in the test using `eventStore.Register`.
23+
24+
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.
25+
26+
## Reference
27+
28+
Read more about multi-stream projections and handling events from multiple sources:
29+
- [Handling Events Coming in an Unknown Order](https://www.architecture-weekly.com/p/handling-events-coming-in-an-unknown)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using System.Text.Json;
2+
3+
namespace IntroductionToEventSourcing.Projections.MultiStream.Tools;
4+
5+
public class Database
6+
{
7+
private readonly Dictionary<string, object> storage = new();
8+
9+
public void Store<T>(Guid id, T obj) where T: class =>
10+
storage[GetId<T>(id)] = obj;
11+
12+
public void Delete<T>(Guid id) =>
13+
storage.Remove(GetId<T>(id));
14+
15+
public T? Get<T>(Guid id) where T: class =>
16+
storage.TryGetValue(GetId<T>(id), out var result) ?
17+
// Clone to simulate getting new instance on loading
18+
JsonSerializer.Deserialize<T>(JsonSerializer.Serialize((T)result))
19+
: null;
20+
21+
private static string GetId<T>(Guid id) => $"{typeof(T).Name}-{id}";
22+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
namespace IntroductionToEventSourcing.Projections.MultiStream.Tools;
2+
3+
public record EventMetadata(
4+
string EventId,
5+
ulong StreamPosition,
6+
ulong LogPosition
7+
)
8+
{
9+
public static EventMetadata For(ulong streamPosition, ulong logPosition) =>
10+
new(Guid.CreateVersion7().ToString(), streamPosition, logPosition);
11+
}
12+
13+
public record EventEnvelope(
14+
object Data,
15+
EventMetadata Metadata
16+
);
17+
18+
public record EventEnvelope<T>(
19+
T Data,
20+
EventMetadata Metadata
21+
): EventEnvelope(Data, Metadata) where T : notnull
22+
{
23+
public new T Data => (T)base.Data;
24+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
namespace IntroductionToEventSourcing.Projections.MultiStream.Tools;
2+
3+
public class EventStore
4+
{
5+
private readonly Dictionary<Type, List<Action<EventEnvelope>>> handlers = new();
6+
private readonly Dictionary<Guid, List<EventEnvelope>> events = new();
7+
8+
public void Register<TEvent>(Action<EventEnvelope<TEvent>> handler) where TEvent : notnull
9+
{
10+
var eventType = typeof(TEvent);
11+
12+
void WrappedHandler(object @event) => handler((EventEnvelope<TEvent>)@event);
13+
14+
if (handlers.TryGetValue(eventType, out var handler1))
15+
handler1.Add(WrappedHandler);
16+
else
17+
handlers.Add(eventType, [WrappedHandler]);
18+
}
19+
20+
public void Append<TEvent>(Guid streamId, TEvent @event) where TEvent : notnull
21+
{
22+
if (!events.ContainsKey(streamId))
23+
events[streamId] = [];
24+
25+
var eventEnvelope = new EventEnvelope<TEvent>(@event,
26+
EventMetadata.For(
27+
(ulong)events[streamId].Count + 1,
28+
(ulong)events.Values.Sum(s => s.Count)
29+
)
30+
);
31+
32+
events[streamId].Add(eventEnvelope);
33+
34+
if (!handlers.TryGetValue(eventEnvelope.Data.GetType(), out var eventHandlers)) return;
35+
36+
foreach (var handle in eventHandlers)
37+
{
38+
handle(eventEnvelope);
39+
}
40+
}
41+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<RootNamespace>IntroductionToEventSourcing.Projections.MultiStream.OutOfOrder</RootNamespace>
6+
</PropertyGroup>
7+
8+
<ItemGroup>
9+
<PackageReference Include="FluentAssertions" />
10+
<PackageReference Include="Microsoft.NET.Test.Sdk" />
11+
<PackageReference Include="GitHubActionsTestLogger">
12+
<PrivateAssets>all</PrivateAssets>
13+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
14+
</PackageReference>
15+
<PackageReference Include="xunit" />
16+
<PackageReference Include="xunit.runner.visualstudio">
17+
<PrivateAssets>all</PrivateAssets>
18+
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
19+
</PackageReference>
20+
<PackageReference Include="coverlet.collector">
21+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
22+
<PrivateAssets>all</PrivateAssets>
23+
</PackageReference>
24+
</ItemGroup>
25+
26+
</Project>

0 commit comments

Comments
 (0)