Skip to content

Commit 9f355cc

Browse files
committed
Adjusted multistream exercises to make them more real-world
1 parent 29f52c1 commit 9f355cc

File tree

16 files changed

+585
-334
lines changed

16 files changed

+585
-334
lines changed

Workshops/IntroductionToEventSourcing/15-Projections.MultiStream/ProjectionsTests.cs

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,6 @@ public record FraudScoreCalculated(
2323
bool IsAcceptable
2424
);
2525

26-
public record PaymentVerificationCompleted(
27-
Guid PaymentId,
28-
bool IsApproved
29-
);
30-
3126
// ENUMS
3227
public enum VerificationStatus
3328
{
@@ -60,18 +55,21 @@ public void MultiStreamProjection_ForPaymentVerification_ShouldSucceed()
6055
var payment2Id = Guid.CreateVersion7();
6156
var payment3Id = Guid.CreateVersion7();
6257
var payment4Id = Guid.CreateVersion7();
58+
var payment5Id = Guid.CreateVersion7();
6359

6460
var order1Id = Guid.CreateVersion7();
6561
var order2Id = Guid.CreateVersion7();
6662
var order3Id = Guid.CreateVersion7();
6763
var order4Id = Guid.CreateVersion7();
64+
var order5Id = Guid.CreateVersion7();
6865

6966
var merchant1Id = Guid.CreateVersion7();
7067
var merchant2Id = Guid.CreateVersion7();
7168

7269
var fraudCheck1Id = Guid.CreateVersion7();
7370
var fraudCheck2Id = Guid.CreateVersion7();
7471
var fraudCheck3Id = Guid.CreateVersion7();
72+
var fraudCheck4Id = Guid.CreateVersion7();
7573

7674
var eventStore = new EventStore();
7775
var database = new Database();
@@ -86,46 +84,49 @@ public void MultiStreamProjection_ForPaymentVerification_ShouldSucceed()
8684
eventStore.Append(payment1Id, new PaymentRecorded(payment1Id, order1Id, 100m));
8785
eventStore.Append(merchant1Id, new MerchantLimitsChecked(payment1Id, merchant1Id, true));
8886
eventStore.Append(fraudCheck1Id, new FraudScoreCalculated(payment1Id, 0.1m, true));
89-
eventStore.Append(payment1Id, new PaymentVerificationCompleted(payment1Id, true));
9087

91-
// Payment 2: Merchant rejected — exceeds merchant limits
88+
// Payment 2: Rejected — merchant limits failed
9289
eventStore.Append(payment2Id, new PaymentRecorded(payment2Id, order2Id, 5000m));
9390
eventStore.Append(merchant2Id, new MerchantLimitsChecked(payment2Id, merchant2Id, false));
9491
eventStore.Append(fraudCheck2Id, new FraudScoreCalculated(payment2Id, 0.2m, true));
95-
eventStore.Append(payment2Id, new PaymentVerificationCompleted(payment2Id, false));
9692

97-
// Payment 3: Fraud rejected — high fraud score
93+
// Payment 3: Rejected — high fraud score
9894
eventStore.Append(payment3Id, new PaymentRecorded(payment3Id, order3Id, 200m));
9995
eventStore.Append(merchant1Id, new MerchantLimitsChecked(payment3Id, merchant1Id, true));
10096
eventStore.Append(fraudCheck3Id, new FraudScoreCalculated(payment3Id, 0.95m, false));
101-
eventStore.Append(payment3Id, new PaymentVerificationCompleted(payment3Id, false));
10297

103-
// Payment 4: Pendingstill awaiting fraud check and final decision
104-
eventStore.Append(payment4Id, new PaymentRecorded(payment4Id, order4Id, 50m));
98+
// Payment 4: Rejectedlarge amount + elevated fraud risk
99+
eventStore.Append(payment4Id, new PaymentRecorded(payment4Id, order4Id, 15000m));
105100
eventStore.Append(merchant1Id, new MerchantLimitsChecked(payment4Id, merchant1Id, true));
101+
eventStore.Append(fraudCheck4Id, new FraudScoreCalculated(payment4Id, 0.6m, true));
102+
103+
// Payment 5: Pending — missing fraud check
104+
eventStore.Append(payment5Id, new PaymentRecorded(payment5Id, order5Id, 50m));
105+
eventStore.Append(merchant1Id, new MerchantLimitsChecked(payment5Id, merchant1Id, true));
106106

107107
// Assert Payment 1: Approved
108108
var payment1 = database.Get<PaymentVerification>(payment1Id)!;
109109
payment1.Should().NotBeNull();
110-
payment1.Id.Should().Be(payment1Id);
111110
payment1.Status.Should().Be(PaymentStatus.Approved);
112111

113-
// Assert Payment 2: Merchant rejected
112+
// Assert Payment 2: Rejected
114113
var payment2 = database.Get<PaymentVerification>(payment2Id)!;
115114
payment2.Should().NotBeNull();
116-
payment2.Id.Should().Be(payment2Id);
117115
payment2.Status.Should().Be(PaymentStatus.Rejected);
118116

119-
// Assert Payment 3: Fraud rejected
117+
// Assert Payment 3: Rejected
120118
var payment3 = database.Get<PaymentVerification>(payment3Id)!;
121119
payment3.Should().NotBeNull();
122-
payment3.Id.Should().Be(payment3Id);
123120
payment3.Status.Should().Be(PaymentStatus.Rejected);
124121

125-
// Assert Payment 4: Pending
122+
// Assert Payment 4: Rejected
126123
var payment4 = database.Get<PaymentVerification>(payment4Id)!;
127124
payment4.Should().NotBeNull();
128-
payment4.Id.Should().Be(payment4Id);
129-
payment4.Status.Should().Be(PaymentStatus.Pending);
125+
payment4.Status.Should().Be(PaymentStatus.Rejected);
126+
127+
// Assert Payment 5: Pending
128+
var payment5 = database.Get<PaymentVerification>(payment5Id)!;
129+
payment5.Should().NotBeNull();
130+
payment5.Status.Should().Be(PaymentStatus.Pending);
130131
}
131132
}
Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,25 @@
11
# Exercise 15 - Multi-Stream Projections
22

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.
3+
Build a multi-stream projection that correlates events from different streams by `PaymentId`.
44

5-
## Scenario: Payment Verification
5+
## Goal
66

7-
A payment verification requires data from three independent checks, each producing events on its own stream:
7+
Learn how to build projections that combine events from multiple event streams into a single read model.
88

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)
9+
## Scenario
1310

14-
All events share a `PaymentId` that ties them to the same payment verification read model.
11+
Events arrive from three different streams (payment, merchant, and fraud check), but they all reference the same `PaymentId`. Your projection must:
1512

16-
## What to implement
13+
1. Collect data from all three event types
14+
2. Store them in a single `PaymentVerification` read model
15+
3. Derive the payment verification status when all data is present
1716

18-
With the [Database](./Tools/Database.cs) interface representing the sample database, implement a `PaymentVerification` read model and projection:
17+
## Steps
1918

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)
19+
1. Create a `PaymentVerificationProjection` class with `Handle` methods for each event type
20+
2. Register your handlers using `eventStore.Register`
21+
3. Implement decision logic in the `FraudScoreCalculated` handler (always last for completed payments):
22+
- Reject if merchant failed
23+
- Reject if fraud score > 0.75
24+
- Reject if amount > 10000 AND fraud score > 0.5
25+
- Otherwise approve

Workshops/IntroductionToEventSourcing/16-Projections.MultiStream.OutOfOrder/ProjectionsTests.cs

Lines changed: 92 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -38,20 +38,74 @@ public enum PaymentStatus
3838
Rejected
3939
}
4040

41-
public enum DataQuality
42-
{
43-
Partial,
44-
Sufficient,
45-
Complete
46-
}
47-
4841
// READ MODEL
4942
public class PaymentVerification
5043
{
5144
public Guid Id { get; set; }
45+
public Guid OrderId { get; set; }
46+
public decimal Amount { get; set; }
47+
public VerificationStatus MerchantLimitStatus { get; set; }
48+
public VerificationStatus FraudStatus { get; set; }
49+
public decimal FraudScore { get; set; }
5250
public PaymentStatus Status { get; set; }
5351
}
5452

53+
public static class DatabaseExtensions
54+
{
55+
public static void GetAndStore<T>(this Database database, Guid id, Func<T, T> update) where T : class, new()
56+
{
57+
var item = database.Get<T>(id) ?? new T();
58+
59+
database.Store(id, update(item));
60+
}
61+
}
62+
63+
public class PaymentVerificationProjection(Database database)
64+
{
65+
public void Handle(EventEnvelope<PaymentRecorded> @event) =>
66+
database.GetAndStore<PaymentVerification>(@event.Data.PaymentId, item =>
67+
{
68+
item.Id = @event.Data.PaymentId;
69+
item.OrderId = @event.Data.OrderId;
70+
item.Amount = @event.Data.Amount;
71+
72+
return item;
73+
});
74+
75+
public void Handle(EventEnvelope<MerchantLimitsChecked> @event) =>
76+
database.GetAndStore<PaymentVerification>(@event.Data.PaymentId, item =>
77+
{
78+
item.MerchantLimitStatus = @event.Data.IsWithinLimits
79+
? VerificationStatus.Passed
80+
: VerificationStatus.Failed;
81+
82+
return item;
83+
});
84+
85+
public void Handle(EventEnvelope<FraudScoreCalculated> @event) =>
86+
database.GetAndStore<PaymentVerification>(@event.Data.PaymentId, item =>
87+
{
88+
item.FraudScore = @event.Data.Score;
89+
item.FraudStatus = @event.Data.IsAcceptable
90+
? VerificationStatus.Passed
91+
: VerificationStatus.Failed;
92+
93+
if (item.Status != PaymentStatus.Pending)
94+
return item;
95+
96+
if (item.MerchantLimitStatus == VerificationStatus.Failed)
97+
item.Status = PaymentStatus.Rejected;
98+
else if (item.FraudScore > 0.75m)
99+
item.Status = PaymentStatus.Rejected;
100+
else if (item.Amount > 10000m && item.FraudScore > 0.5m)
101+
item.Status = PaymentStatus.Rejected;
102+
else
103+
item.Status = PaymentStatus.Approved;
104+
105+
return item;
106+
});
107+
}
108+
55109
public class ProjectionsTests
56110
{
57111
[Fact]
@@ -62,11 +116,13 @@ public void MultiStreamProjection_WithOutOfOrderEvents_ShouldSucceed()
62116
var payment2Id = Guid.CreateVersion7();
63117
var payment3Id = Guid.CreateVersion7();
64118
var payment4Id = Guid.CreateVersion7();
119+
var payment5Id = Guid.CreateVersion7();
65120

66121
var order1Id = Guid.CreateVersion7();
67122
var order2Id = Guid.CreateVersion7();
68123
var order3Id = Guid.CreateVersion7();
69124
var order4Id = Guid.CreateVersion7();
125+
var order5Id = Guid.CreateVersion7();
70126

71127
var merchant1Id = Guid.CreateVersion7();
72128
var merchant2Id = Guid.CreateVersion7();
@@ -79,56 +135,63 @@ public void MultiStreamProjection_WithOutOfOrderEvents_ShouldSucceed()
79135
var eventStore = new EventStore();
80136
var database = new Database();
81137

82-
// TODO:
83-
// 1. Create a PaymentVerificationProjection class that handles each event type.
84-
// 2. Each handler must work even if events arrive out of order (e.g., fraud score before payment).
85-
// 3. The projection should derive the Status based on available data:
86-
// - Pending: waiting for required data
87-
// - Rejected: merchant limits failed OR fraud score unacceptable
88-
// - Approved: all checks passed
89-
// 4. Register your event handlers using `eventStore.Register`.
138+
// TODO: This projection was built assuming ordered events. Run the test — it fails.
139+
// Events can arrive out of order (e.g. from different RabbitMQ queues or Kafka topics).
140+
// Fix it to handle out-of-order events and derive the verification decision.
90141

91-
// Payment 1: Approved — events arrive out of order (fraud score first!)
142+
var projection = new PaymentVerificationProjection(database);
143+
144+
eventStore.Register<PaymentRecorded>(projection.Handle);
145+
eventStore.Register<MerchantLimitsChecked>(projection.Handle);
146+
eventStore.Register<FraudScoreCalculated>(projection.Handle);
147+
148+
// Payment 1: Approved — FraudScore arrives first
92149
eventStore.Append(fraudCheck1Id, new FraudScoreCalculated(payment1Id, 0.1m, true));
93150
eventStore.Append(merchant1Id, new MerchantLimitsChecked(payment1Id, merchant1Id, true));
94151
eventStore.Append(payment1Id, new PaymentRecorded(payment1Id, order1Id, 100m));
95152

96-
// Payment 2: Merchant rejected — merchant check arrives first
153+
// Payment 2: Rejected — Merchant fails, arrives first
97154
eventStore.Append(merchant2Id, new MerchantLimitsChecked(payment2Id, merchant2Id, false));
98155
eventStore.Append(fraudCheck2Id, new FraudScoreCalculated(payment2Id, 0.2m, true));
99156
eventStore.Append(payment2Id, new PaymentRecorded(payment2Id, order2Id, 5000m));
100157

101-
// Payment 3: Fraud rejected — payment recorded last
158+
// Payment 3: Rejected — high fraud score arrives first
102159
eventStore.Append(fraudCheck3Id, new FraudScoreCalculated(payment3Id, 0.95m, false));
103160
eventStore.Append(merchant1Id, new MerchantLimitsChecked(payment3Id, merchant1Id, true));
104161
eventStore.Append(payment3Id, new PaymentRecorded(payment3Id, order3Id, 200m));
105162

106-
// Payment 4: Pendingmissing fraud check (payment recorded, merchant checked, but no fraud score yet)
107-
eventStore.Append(payment4Id, new PaymentRecorded(payment4Id, order4Id, 50m));
163+
// Payment 4: Rejected — fraud 0.6 looks OK until 15000 amount arrives last
164+
eventStore.Append(fraudCheck4Id, new FraudScoreCalculated(payment4Id, 0.6m, true));
108165
eventStore.Append(merchant1Id, new MerchantLimitsChecked(payment4Id, merchant1Id, true));
166+
eventStore.Append(payment4Id, new PaymentRecorded(payment4Id, order4Id, 15000m));
109167

110-
// Assert Payment 1: Approved (all data arrived, all checks passed)
168+
// Payment 5: Pending — no fraud check
169+
eventStore.Append(merchant1Id, new MerchantLimitsChecked(payment5Id, merchant1Id, true));
170+
eventStore.Append(payment5Id, new PaymentRecorded(payment5Id, order5Id, 50m));
171+
172+
// Assert Payment 1: Approved
111173
var payment1 = database.Get<PaymentVerification>(payment1Id)!;
112174
payment1.Should().NotBeNull();
113-
payment1.Id.Should().Be(payment1Id);
114175
payment1.Status.Should().Be(PaymentStatus.Approved);
115176

116-
// Assert Payment 2: Merchant rejected
177+
// Assert Payment 2: Rejected
117178
var payment2 = database.Get<PaymentVerification>(payment2Id)!;
118179
payment2.Should().NotBeNull();
119-
payment2.Id.Should().Be(payment2Id);
120180
payment2.Status.Should().Be(PaymentStatus.Rejected);
121181

122-
// Assert Payment 3: Fraud rejected
182+
// Assert Payment 3: Rejected
123183
var payment3 = database.Get<PaymentVerification>(payment3Id)!;
124184
payment3.Should().NotBeNull();
125-
payment3.Id.Should().Be(payment3Id);
126185
payment3.Status.Should().Be(PaymentStatus.Rejected);
127186

128-
// Assert Payment 4: Pending (waiting for fraud check)
187+
// Assert Payment 4: Rejected
129188
var payment4 = database.Get<PaymentVerification>(payment4Id)!;
130189
payment4.Should().NotBeNull();
131-
payment4.Id.Should().Be(payment4Id);
132-
payment4.Status.Should().Be(PaymentStatus.Pending);
190+
payment4.Status.Should().Be(PaymentStatus.Rejected);
191+
192+
// Assert Payment 5: Pending
193+
var payment5 = database.Get<PaymentVerification>(payment5Id)!;
194+
payment5.Should().NotBeNull();
195+
payment5.Status.Should().Be(PaymentStatus.Pending);
133196
}
134197
}
Lines changed: 9 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,19 @@
11
# Exercise 16 - Multi-Stream Projections with Out-of-Order Events
22

3-
In exercise 15 you built multi-stream projections assuming events arrive in a predictable order. The real world isn't that kind. Events arrive **in any order**, especially when they come from different services.
3+
Fix the projection from Exercise 15 to handle out-of-order events.
44

5-
## Scenario: Payment Verification with Race Conditions
5+
## Goal
66

7-
The same payment verification domain, but now events arrive scrambled:
7+
Learn how to build resilient projections that work even when events arrive in any order.
88

9-
1. **FraudScoreCalculated** might arrive before **PaymentRecorded**
10-
2. **MerchantLimitsChecked** could be first or last
11-
3. No **PaymentVerificationCompleted** event — your projection derives the decision when enough data arrives
9+
## Context
1210

13-
This teaches you to build **resilient read models** using the phantom record pattern.
11+
Events can arrive out of order (e.g., from different RabbitMQ queues or Kafka topics). The projection from Exercise 15 was built assuming ordered events — run the test to see it fail.
1412

15-
## Key Differences from Exercise 15
13+
For example, `FraudScoreCalculated` might fire before `PaymentRecorded`, meaning the Amount is 0 when you try to make the decision.
1614

17-
1. **No final decision event** — the projection determines approval/rejection based on available data
18-
2. **Handle partial state** — the read model exists even with incomplete information
19-
3. **Derive status** — when you have all required data, calculate the final status
15+
**Emit event when payment verification is completed**.
2016

21-
## What to implement
17+
## Decision Logic
2218

23-
With the [Database](./Tools/Database.cs) interface representing the sample database, implement a resilient `PaymentVerification` projection:
24-
25-
1. Define additional `PaymentVerification` properties to store data from each event — but design it to handle missing data
26-
2. Create a `PaymentVerificationProjection` class with typed `Handle` methods for each event
27-
3. Each handler should work even if other events haven't arrived yet
28-
4. When enough data exists, derive the final `Status` (Approved/Rejected/Pending)
29-
5. Register handlers in the test using `eventStore.Register`
30-
31-
The test will append events **in scrambled order** and verify your projection handles partial state correctly.
32-
33-
## Reference
34-
35-
Read more about handling out-of-order events and phantom records:
36-
- [Dealing with Race Conditions in Event-Driven Architecture](https://www.architecture-weekly.com/p/dealing-with-race-conditions-in-event)
19+
Only derive a final status when you have all three pieces of data (payment, merchant check, fraud check). Then apply the same rules as Exercise 15.

0 commit comments

Comments
 (0)