From 4f57da664873bdc314c4d201a176c155be8a985f Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Tue, 3 Sep 2024 18:47:52 +0200 Subject: [PATCH] Added example Saga implementation --- .../02-EntitiesDefinition/Core/EventBus.cs | 6 +- .../03-BusinessProcesses/Core/CommandBus.cs | 12 +- .../03-BusinessProcesses/Core/EventBus.cs | 12 +- .../Core/MessageCatcher.cs | 2 +- .../BusinessProcessTests.cs | 224 ++++++++++++++++ .../EntityDefinitionTests.cs | 33 +-- .../BusinessProcessTests.cs | 34 +-- .../EntityDefinitionTests.cs | 28 +- .../GuestStayAccounts/GuestStayAccount.cs | 39 ++- .../GuestStayFacade.cs | 6 +- .../GuestStayAccounts/GuestStayAccount.cs | 39 ++- .../GuestStayFacade.cs | 4 +- .../03-BusinessProcesses/Core/CommandBus.cs | 34 +++ .../03-BusinessProcesses/Core/Database.cs | 32 +++ .../03-BusinessProcesses/Core/EventBus.cs | 44 ++++ .../Core/MessageCatcher.cs | 26 ++ .../03-BusinessProcesses/UnitTest1.cs | 11 - .../Version1-Aggregates/Core/Aggregate.cs | 30 +++ .../EntityDefinitionTests.cs | 172 ++++++++++++ .../GroupCheckouts/GroupCheckoutEvent.cs | 39 +++ .../GuestStayAccounts/GuestStayAccount.cs | 128 +++++++++ .../Version1-Aggregates/GuestStayFacade.cs | 100 +++++++ .../Core/DictionaryExtensions.cs | 17 ++ .../Sagas/BusinessProcessTests.cs | 244 ++++++++++++++++++ .../Sagas/EntityDefinitionTests.cs | 148 +++++++++++ .../Sagas/GroupCheckouts/GroupCheckout.cs | 156 +++++++++++ .../GroupCheckouts/GroupCheckoutFacade.cs | 82 ++++++ .../Sagas/GroupCheckouts/GroupCheckoutSaga.cs | 60 +++++ .../GroupCheckouts/GroupCheckoutsConfig.cs | 36 +++ .../GuestStayAccounts/GroupCheckoutsConfig.cs | 18 ++ .../GuestStayAccounts/GuestStayAccount.cs | 120 +++++++++ .../GuestStayAccounts/GuestStayFacade.cs | 86 ++++++ 32 files changed, 1894 insertions(+), 128 deletions(-) create mode 100644 Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version1-Aggregates/BusinessProcessTests.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Core/CommandBus.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Core/Database.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Core/EventBus.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Core/MessageCatcher.cs delete mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/UnitTest1.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/Core/Aggregate.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/EntityDefinitionTests.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/GroupCheckouts/GroupCheckoutEvent.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/GuestStayAccounts/GuestStayAccount.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/GuestStayFacade.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Core/DictionaryExtensions.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/BusinessProcessTests.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/EntityDefinitionTests.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GroupCheckouts/GroupCheckout.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GroupCheckouts/GroupCheckoutFacade.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GroupCheckouts/GroupCheckoutSaga.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GroupCheckouts/GroupCheckoutsConfig.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GuestStayAccounts/GroupCheckoutsConfig.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GuestStayAccounts/GuestStayAccount.cs create mode 100644 Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GuestStayAccounts/GuestStayFacade.cs diff --git a/Workshops/EventDrivenArchitecture/02-EntitiesDefinition/Core/EventBus.cs b/Workshops/EventDrivenArchitecture/02-EntitiesDefinition/Core/EventBus.cs index e3eadba1..bbb312b7 100644 --- a/Workshops/EventDrivenArchitecture/02-EntitiesDefinition/Core/EventBus.cs +++ b/Workshops/EventDrivenArchitecture/02-EntitiesDefinition/Core/EventBus.cs @@ -9,12 +9,12 @@ public ValueTask Publish(object[] events, CancellationToken _) { foreach (var @event in events) { - if (!eventHandlers.TryGetValue(@event.GetType(), out var handlers)) - continue; - foreach (var middleware in middlewares) middleware(@event); + if (!eventHandlers.TryGetValue(@event.GetType(), out var handlers)) + continue; + foreach (var handler in handlers) handler(@event); } diff --git a/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Core/CommandBus.cs b/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Core/CommandBus.cs index dce95a59..86178aa7 100644 --- a/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Core/CommandBus.cs +++ b/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Core/CommandBus.cs @@ -5,7 +5,7 @@ namespace BusinessProcesses.Core; public class CommandBus { - public ValueTask Send(object[] commands, CancellationToken _) + public async ValueTask Send(object[] commands, CancellationToken ct) { foreach (var command in commands) { @@ -15,20 +15,18 @@ public ValueTask Send(object[] commands, CancellationToken _) foreach (var middleware in middlewares) middleware(command); - handler(command); + await handler(command, ct); } - - return ValueTask.CompletedTask; } - public void Handle(Action eventHandler) + public void Handle(Func eventHandler) { - commandHandlers[typeof(T)] = x => eventHandler((T)x); + commandHandlers[typeof(T)] = (command, ct) => eventHandler((T)command, ct); } public void Use(Action middleware) => middlewares.Add(middleware); - private readonly ConcurrentDictionary> commandHandlers = new(); + private readonly ConcurrentDictionary> commandHandlers = new(); private readonly List> middlewares = []; } diff --git a/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Core/EventBus.cs b/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Core/EventBus.cs index 9532b4e6..97b2f9aa 100644 --- a/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Core/EventBus.cs +++ b/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Core/EventBus.cs @@ -4,7 +4,7 @@ namespace BusinessProcesses.Core; public class EventBus { - public ValueTask Publish(object[] events, CancellationToken _) + public async ValueTask Publish(object[] events, CancellationToken ct) { foreach (var @event in events) { @@ -15,15 +15,13 @@ public ValueTask Publish(object[] events, CancellationToken _) middleware(@event); foreach (var handler in handlers) - handler(@event); + await handler(@event, ct); } - - return ValueTask.CompletedTask; } - public void Subscribe(Action eventHandler) + public void Subscribe(Func eventHandler) { - Action handler = x => eventHandler((T)x); + Func handler = (@event, ct) => eventHandler((T)@event, ct); eventHandlers.AddOrUpdate( typeof(T), @@ -39,6 +37,6 @@ public void Subscribe(Action eventHandler) public void Use(Action middleware) => middlewares.Add(middleware); - private readonly ConcurrentDictionary>> eventHandlers = new(); + private readonly ConcurrentDictionary>> eventHandlers = new(); private readonly List> middlewares = []; } diff --git a/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Core/MessageCatcher.cs b/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Core/MessageCatcher.cs index f9809598..94421948 100644 --- a/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Core/MessageCatcher.cs +++ b/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Core/MessageCatcher.cs @@ -2,7 +2,7 @@ namespace BusinessProcesses.Core; -public class EventCatcher +public class MessageCatcher { public List Published { get; } = []; diff --git a/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version1-Aggregates/BusinessProcessTests.cs b/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version1-Aggregates/BusinessProcessTests.cs new file mode 100644 index 00000000..22107d65 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version1-Aggregates/BusinessProcessTests.cs @@ -0,0 +1,224 @@ +using Bogus; +using BusinessProcesses.Core; +using BusinessProcesses.Version1_Aggregates.GroupCheckouts; +using BusinessProcesses.Version1_Aggregates.GuestStayAccounts; +using Xunit; +using Xunit.Abstractions; +using Database = BusinessProcesses.Core.Database; + +namespace BusinessProcesses.Version1_Aggregates; + +using static GuestStayAccountCommand; +using static GuestStayAccountEvent; +using static GroupCheckoutCommand; + +public class BusinessProcessTests +{ + [Fact] + public async Task GroupCheckoutForMultipleGuestStayWithoutPaymentsAndCharges_ShouldComplete() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await guestStayFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveMessages( + [ + new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), + new CheckOutGuest(guestStays[0], now, groupCheckoutId), + new GuestCheckedOut(guestStays[0], now, groupCheckoutId), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now), + new CheckOutGuest(guestStays[1], now, groupCheckoutId), + new GuestCheckedOut(guestStays[1], now, groupCheckoutId), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[1], now), + new CheckOutGuest(guestStays[2], now, groupCheckoutId), + new GuestCheckedOut(guestStays[2], now, groupCheckoutId), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GroupCheckoutCompleted(groupCheckoutId, guestStays, now), + ] + ); + } + + [Fact] + public async Task GroupCheckoutForMultipleGuestStayWithAllStaysSettled_ShouldComplete() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + decimal[] amounts = [generate.Finance.Amount(), generate.Finance.Amount(), generate.Finance.Amount()]; + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[0], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[0], now.AddHours(-1))); + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[1], amounts[1], now.AddHours(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[1], amounts[1], now.AddHours(-2))); + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[2], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[2] / 2, now.AddHours(-1))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[2] / 2, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await guestStayFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveMessages( + [ + new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), + new CheckOutGuest(guestStays[0], now, groupCheckoutId), + new GuestCheckedOut(guestStays[0], now, groupCheckoutId), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now), + new CheckOutGuest(guestStays[1], now, groupCheckoutId), + new GuestCheckedOut(guestStays[1], now, groupCheckoutId), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[1], now), + new CheckOutGuest(guestStays[2], now, groupCheckoutId), + new GuestCheckedOut(guestStays[2], now, groupCheckoutId), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GroupCheckoutCompleted(groupCheckoutId, guestStays, now), + ] + ); + } + + [Fact] + public async Task GroupCheckoutForMultipleGuestStayWithOneSettledAndRestUnsettled_ShouldFail() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + decimal[] amounts = [generate.Finance.Amount(), generate.Finance.Amount(), generate.Finance.Amount()]; + + // 🟢 settled + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[0], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[0], now.AddHours(-1))); + + // 🛑 payment without charge + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[1], amounts[1], now.AddHours(-1))); + + // 🛑 payment without charge + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[2], amounts[2], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[2], amounts[2] / 2, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await guestStayFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveMessages( + [ + new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), + new CheckOutGuest(guestStays[0], now, groupCheckoutId), + new GuestCheckedOut(guestStays[0], now, groupCheckoutId), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now), + new CheckOutGuest(guestStays[1], now, groupCheckoutId), + new GuestCheckoutFailed(guestStays[1], GuestCheckoutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[1], now), + new CheckOutGuest(guestStays[2], now, groupCheckoutId), + new GuestCheckoutFailed(guestStays[2], GuestCheckoutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GroupCheckoutFailed( + groupCheckoutId, + [guestStays[0]], + [guestStays[1], guestStays[2]], + now + ), + ] + ); + } + + + [Fact] + public async Task GroupCheckoutForMultipleGuestStayWithAllUnsettled_ShouldFail() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + decimal[] amounts = [generate.Finance.Amount(), generate.Finance.Amount(), generate.Finance.Amount()]; + + // 🛑 charge without payment + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[0], now.AddHours(-2))); + + // 🛑 payment without charge + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[1], amounts[1], now.AddHours(-1))); + + // 🛑 payment without charge + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[2], amounts[2], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[2], amounts[2] / 2, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await guestStayFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveMessages( + [ + new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), + new CheckOutGuest(guestStays[0], now, groupCheckoutId), + new GuestCheckoutFailed(guestStays[0], GuestCheckoutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[0], now), + new CheckOutGuest(guestStays[1], now, groupCheckoutId), + new GuestCheckoutFailed(guestStays[1], GuestCheckoutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[1], now), + new CheckOutGuest(guestStays[2], now, groupCheckoutId), + new GuestCheckoutFailed(guestStays[2], GuestCheckoutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GroupCheckoutFailed( + groupCheckoutId, + [], + [guestStays[0], guestStays[1], guestStays[2]], + now + ), + ] + ); + } + + private readonly Database database = new(); + private readonly EventBus eventBus = new(); + private readonly CommandBus commandBus = new(); + private readonly MessageCatcher publishedMessages = new(); + private readonly GuestStayFacade guestStayFacade; + private readonly Faker generate = new(); + private readonly DateTimeOffset now = DateTimeOffset.Now; + private readonly ITestOutputHelper testOutputHelper; + + public BusinessProcessTests(ITestOutputHelper testOutputHelper) + { + this.testOutputHelper = testOutputHelper; + guestStayFacade = new GuestStayFacade(database, eventBus); + + eventBus.Use(publishedMessages.Catch); + } +} diff --git a/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version1-Aggregates/EntityDefinitionTests.cs b/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version1-Aggregates/EntityDefinitionTests.cs index f9cce08d..e9a8b07f 100644 --- a/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version1-Aggregates/EntityDefinitionTests.cs +++ b/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version1-Aggregates/EntityDefinitionTests.cs @@ -20,13 +20,13 @@ public async Task CheckingInGuest_Succeeds() // Given var guestStayId = Guid.NewGuid(); var command = new CheckInGuest(guestStayId, now); - publishedEvents.Reset(); + publishedMessages.Reset(); // When await guestStayFacade.CheckInGuest(command); // Then - publishedEvents.ShouldReceiveSingleMessage(new GuestCheckedIn(guestStayId, now)); + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckedIn(guestStayId, now)); } [Fact] @@ -35,7 +35,7 @@ public async Task RecordingChargeForCheckedInGuest_Succeeds() // Given var guestStayId = Guid.NewGuid(); await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); - publishedEvents.Reset(); + publishedMessages.Reset(); // And var amount = generate.Finance.Amount(); var command = new RecordCharge(guestStayId, amount, now); @@ -44,7 +44,7 @@ public async Task RecordingChargeForCheckedInGuest_Succeeds() await guestStayFacade.RecordCharge(command); // Then - publishedEvents.ShouldReceiveSingleMessage(new ChargeRecorded(guestStayId, amount, now)); + publishedMessages.ShouldReceiveSingleMessage(new ChargeRecorded(guestStayId, amount, now)); } [Fact] @@ -53,7 +53,7 @@ public async Task RecordingPaymentForCheckedInGuest_Succeeds() // Given var guestStayId = Guid.NewGuid(); await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); - publishedEvents.Reset(); + publishedMessages.Reset(); // And var amount = generate.Finance.Amount(); var command = new RecordPayment(guestStayId, amount, now); @@ -62,7 +62,7 @@ public async Task RecordingPaymentForCheckedInGuest_Succeeds() await guestStayFacade.RecordPayment(command); // Then - publishedEvents.ShouldReceiveSingleMessage(new PaymentRecorded(guestStayId, amount, now)); + publishedMessages.ShouldReceiveSingleMessage(new PaymentRecorded(guestStayId, amount, now)); } [Fact] @@ -72,7 +72,7 @@ public async Task RecordingPaymentForCheckedInGuestWithCharge_Succeeds() var guestStayId = Guid.NewGuid(); await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); await guestStayFacade.RecordCharge(new RecordCharge(guestStayId, generate.Finance.Amount(), now.AddHours(-1))); - publishedEvents.Reset(); + publishedMessages.Reset(); // And var amount = generate.Finance.Amount(); var command = new RecordPayment(guestStayId, amount, now); @@ -81,7 +81,7 @@ public async Task RecordingPaymentForCheckedInGuestWithCharge_Succeeds() await guestStayFacade.RecordPayment(command); // Then - publishedEvents.ShouldReceiveSingleMessage(new PaymentRecorded(guestStayId, amount, now)); + publishedMessages.ShouldReceiveSingleMessage(new PaymentRecorded(guestStayId, amount, now)); } [Fact] @@ -94,7 +94,7 @@ public async Task CheckingOutGuestWithSettledBalance_Succeeds() await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); await guestStayFacade.RecordCharge(new RecordCharge(guestStayId, amount, now.AddHours(-2))); await guestStayFacade.RecordPayment(new RecordPayment(guestStayId, amount, now.AddHours(-1))); - publishedEvents.Reset(); + publishedMessages.Reset(); // And var command = new CheckOutGuest(guestStayId, now); @@ -102,7 +102,7 @@ public async Task CheckingOutGuestWithSettledBalance_Succeeds() await guestStayFacade.CheckOutGuest(command); // Then - publishedEvents.ShouldReceiveSingleMessage(new GuestCheckedOut(guestStayId, now)); + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckedOut(guestStayId, now)); } [Fact] @@ -115,7 +115,7 @@ public async Task CheckingOutGuestWithSettledBalance_FailsWithGuestCheckoutFaile await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); await guestStayFacade.RecordCharge(new RecordCharge(guestStayId, amount + 10, now.AddHours(-2))); await guestStayFacade.RecordPayment(new RecordPayment(guestStayId, amount, now.AddHours(-1))); - publishedEvents.Reset(); + publishedMessages.Reset(); // And var command = new CheckOutGuest(guestStayId, now); @@ -130,7 +130,7 @@ public async Task CheckingOutGuestWithSettledBalance_FailsWithGuestCheckoutFaile } // Then - publishedEvents.ShouldReceiveSingleMessage(new GuestCheckoutFailed(guestStayId, GuestCheckoutFailed.FailureReason.BalanceNotSettled, now)); + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckoutFailed(guestStayId, GuestCheckoutFailed.FailureReason.BalanceNotSettled, now)); } [Fact] @@ -142,7 +142,7 @@ public async Task GroupCheckoutForMultipleGuestStay_ShouldBeInitiated() await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); - publishedEvents.Reset(); + publishedMessages.Reset(); // And var groupCheckoutId = Guid.NewGuid(); var clerkId = Guid.NewGuid(); @@ -152,12 +152,12 @@ public async Task GroupCheckoutForMultipleGuestStay_ShouldBeInitiated() await guestStayFacade.InitiateGroupCheckout(command); // Then - publishedEvents.ShouldReceiveSingleMessage(new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now)); + publishedMessages.ShouldReceiveSingleMessage(new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now)); } private readonly Database database = new(); private readonly EventBus eventBus = new(); - private readonly EventCatcher publishedEvents = new(); + private readonly MessageCatcher publishedMessages = new(); private readonly GuestStayFacade guestStayFacade; private readonly Faker generate = new(); private readonly DateTimeOffset now = DateTimeOffset.Now; @@ -167,6 +167,7 @@ public EntityDefinitionTests(ITestOutputHelper testOutputHelper) { this.testOutputHelper = testOutputHelper; guestStayFacade = new GuestStayFacade(database, eventBus); - eventBus.Use(publishedEvents.Catch); + + eventBus.Use(publishedMessages.Catch); } } diff --git a/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version2-ImmutableEntities/BusinessProcessTests.cs b/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version2-ImmutableEntities/BusinessProcessTests.cs index c2563800..bdfae73a 100644 --- a/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version2-ImmutableEntities/BusinessProcessTests.cs +++ b/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version2-ImmutableEntities/BusinessProcessTests.cs @@ -8,8 +8,8 @@ namespace BusinessProcesses.Version2_ImmutableEntities; -using static GuestStayAccountEvent; using static GuestStayAccountCommand; +using static GuestStayAccountEvent; using static GroupCheckoutCommand; public class BusinessProcessTests @@ -23,7 +23,7 @@ public async Task GroupCheckoutForMultipleGuestStayWithoutPaymentsAndCharges_Sho await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); - publishedEvents.Reset(); + publishedMessages.Reset(); // And var groupCheckoutId = Guid.NewGuid(); var clerkId = Guid.NewGuid(); @@ -33,7 +33,7 @@ public async Task GroupCheckoutForMultipleGuestStayWithoutPaymentsAndCharges_Sho await guestStayFacade.InitiateGroupCheckout(command); // Then - publishedEvents.ShouldReceiveMessages( + publishedMessages.ShouldReceiveMessages( [ new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), new CheckOutGuest(guestStays[0], now, groupCheckoutId), @@ -69,7 +69,7 @@ public async Task GroupCheckoutForMultipleGuestStayWithAllStaysSettled_ShouldCom await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[2], now.AddHours(-2))); await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[2] / 2, now.AddHours(-1))); await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[2] / 2, now.AddHours(-1))); - publishedEvents.Reset(); + publishedMessages.Reset(); // And var groupCheckoutId = Guid.NewGuid(); var clerkId = Guid.NewGuid(); @@ -79,7 +79,7 @@ public async Task GroupCheckoutForMultipleGuestStayWithAllStaysSettled_ShouldCom await guestStayFacade.InitiateGroupCheckout(command); // Then - publishedEvents.ShouldReceiveMessages( + publishedMessages.ShouldReceiveMessages( [ new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), new CheckOutGuest(guestStays[0], now, groupCheckoutId), @@ -114,9 +114,9 @@ public async Task GroupCheckoutForMultipleGuestStayWithOneSettledAndRestUnsettle // 🛑 payment without charge await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); - await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[2], now.AddHours(-2))); - await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[2] / 2, now.AddHours(-1))); - publishedEvents.Reset(); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[2], amounts[2], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[2], amounts[2] / 2, now.AddHours(-1))); + publishedMessages.Reset(); // And var groupCheckoutId = Guid.NewGuid(); var clerkId = Guid.NewGuid(); @@ -126,7 +126,7 @@ public async Task GroupCheckoutForMultipleGuestStayWithOneSettledAndRestUnsettle await guestStayFacade.InitiateGroupCheckout(command); // Then - publishedEvents.ShouldReceiveMessages( + publishedMessages.ShouldReceiveMessages( [ new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), new CheckOutGuest(guestStays[0], now, groupCheckoutId), @@ -168,9 +168,9 @@ public async Task GroupCheckoutForMultipleGuestStayWithAllUnsettled_ShouldFail() // 🛑 payment without charge await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); - await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[2], now.AddHours(-2))); - await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[2] / 2, now.AddHours(-1))); - publishedEvents.Reset(); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[2], amounts[2], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[2], amounts[2] / 2, now.AddHours(-1))); + publishedMessages.Reset(); // And var groupCheckoutId = Guid.NewGuid(); var clerkId = Guid.NewGuid(); @@ -180,13 +180,13 @@ public async Task GroupCheckoutForMultipleGuestStayWithAllUnsettled_ShouldFail() await guestStayFacade.InitiateGroupCheckout(command); // Then - publishedEvents.ShouldReceiveMessages( + publishedMessages.ShouldReceiveMessages( [ new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), new CheckOutGuest(guestStays[0], now, groupCheckoutId), new GuestCheckoutFailed(guestStays[0], GuestCheckoutFailed.FailureReason.BalanceNotSettled, now, groupCheckoutId), - new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now), + new GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[0], now), new CheckOutGuest(guestStays[1], now, groupCheckoutId), new GuestCheckoutFailed(guestStays[1], GuestCheckoutFailed.FailureReason.BalanceNotSettled, now, groupCheckoutId), @@ -207,7 +207,8 @@ public async Task GroupCheckoutForMultipleGuestStayWithAllUnsettled_ShouldFail() private readonly Database database = new(); private readonly EventBus eventBus = new(); - private readonly EventCatcher publishedEvents = new(); + private readonly CommandBus commandBus = new(); + private readonly MessageCatcher publishedMessages = new(); private readonly GuestStayFacade guestStayFacade; private readonly Faker generate = new(); private readonly DateTimeOffset now = DateTimeOffset.Now; @@ -217,6 +218,7 @@ public BusinessProcessTests(ITestOutputHelper testOutputHelper) { this.testOutputHelper = testOutputHelper; guestStayFacade = new GuestStayFacade(database, eventBus); - eventBus.Use(publishedEvents.Catch); + + eventBus.Use(publishedMessages.Catch); } } diff --git a/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version2-ImmutableEntities/EntityDefinitionTests.cs b/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version2-ImmutableEntities/EntityDefinitionTests.cs index f711bcc3..1737815f 100644 --- a/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version2-ImmutableEntities/EntityDefinitionTests.cs +++ b/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version2-ImmutableEntities/EntityDefinitionTests.cs @@ -20,13 +20,13 @@ public async Task CheckingInGuest_Succeeds() // Given var guestStayId = Guid.NewGuid(); var command = new CheckInGuest(guestStayId, now); - publishedEvents.Reset(); + publishedMessages.Reset(); // When await guestStayFacade.CheckInGuest(command); // Then - publishedEvents.ShouldReceiveSingleMessage(new GuestCheckedIn(guestStayId, now)); + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckedIn(guestStayId, now)); } [Fact] @@ -35,7 +35,7 @@ public async Task RecordingChargeForCheckedInGuest_Succeeds() // Given var guestStayId = Guid.NewGuid(); await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); - publishedEvents.Reset(); + publishedMessages.Reset(); // And var amount = generate.Finance.Amount(); var command = new RecordCharge(guestStayId, amount, now); @@ -44,7 +44,7 @@ public async Task RecordingChargeForCheckedInGuest_Succeeds() await guestStayFacade.RecordCharge(command); // Then - publishedEvents.ShouldReceiveSingleMessage(new ChargeRecorded(guestStayId, amount, now)); + publishedMessages.ShouldReceiveSingleMessage(new ChargeRecorded(guestStayId, amount, now)); } [Fact] @@ -53,7 +53,7 @@ public async Task RecordingPaymentForCheckedInGuest_Succeeds() // Given var guestStayId = Guid.NewGuid(); await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); - publishedEvents.Reset(); + publishedMessages.Reset(); // And var amount = generate.Finance.Amount(); var command = new RecordPayment(guestStayId, amount, now); @@ -62,7 +62,7 @@ public async Task RecordingPaymentForCheckedInGuest_Succeeds() await guestStayFacade.RecordPayment(command); // Then - publishedEvents.ShouldReceiveSingleMessage(new PaymentRecorded(guestStayId, amount, now)); + publishedMessages.ShouldReceiveSingleMessage(new PaymentRecorded(guestStayId, amount, now)); } [Fact] @@ -72,7 +72,7 @@ public async Task RecordingPaymentForCheckedInGuestWithCharge_Succeeds() var guestStayId = Guid.NewGuid(); await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); await guestStayFacade.RecordCharge(new RecordCharge(guestStayId, generate.Finance.Amount(), now.AddHours(-1))); - publishedEvents.Reset(); + publishedMessages.Reset(); // And var amount = generate.Finance.Amount(); var command = new RecordPayment(guestStayId, amount, now); @@ -81,7 +81,7 @@ public async Task RecordingPaymentForCheckedInGuestWithCharge_Succeeds() await guestStayFacade.RecordPayment(command); // Then - publishedEvents.ShouldReceiveSingleMessage(new PaymentRecorded(guestStayId, amount, now)); + publishedMessages.ShouldReceiveSingleMessage(new PaymentRecorded(guestStayId, amount, now)); } [Fact] @@ -94,7 +94,7 @@ public async Task CheckingOutGuestWithSettledBalance_Succeeds() await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); await guestStayFacade.RecordCharge(new RecordCharge(guestStayId, amount, now.AddHours(-2))); await guestStayFacade.RecordPayment(new RecordPayment(guestStayId, amount, now.AddHours(-1))); - publishedEvents.Reset(); + publishedMessages.Reset(); // And var command = new CheckOutGuest(guestStayId, now); @@ -102,7 +102,7 @@ public async Task CheckingOutGuestWithSettledBalance_Succeeds() await guestStayFacade.CheckOutGuest(command); // Then - publishedEvents.ShouldReceiveSingleMessage(new GuestCheckedOut(guestStayId, now)); + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckedOut(guestStayId, now)); } [Fact] @@ -115,7 +115,7 @@ public async Task CheckingOutGuestWithSettledBalance_FailsWithGuestCheckoutFaile await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); await guestStayFacade.RecordCharge(new RecordCharge(guestStayId, amount + 10, now.AddHours(-2))); await guestStayFacade.RecordPayment(new RecordPayment(guestStayId, amount, now.AddHours(-1))); - publishedEvents.Reset(); + publishedMessages.Reset(); // And var command = new CheckOutGuest(guestStayId, now); @@ -130,12 +130,12 @@ public async Task CheckingOutGuestWithSettledBalance_FailsWithGuestCheckoutFaile } // Then - publishedEvents.ShouldReceiveSingleMessage(new GuestCheckoutFailed(guestStayId, GuestCheckoutFailed.FailureReason.BalanceNotSettled, now)); + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckoutFailed(guestStayId, GuestCheckoutFailed.FailureReason.BalanceNotSettled, now)); } private readonly Database database = new(); private readonly EventBus eventBus = new(); - private readonly EventCatcher publishedEvents = new(); + private readonly MessageCatcher publishedMessages = new(); private readonly GuestStayFacade guestStayFacade; private readonly Faker generate = new(); private readonly DateTimeOffset now = DateTimeOffset.Now; @@ -145,6 +145,6 @@ public EntityDefinitionTests(ITestOutputHelper testOutputHelper) { this.testOutputHelper = testOutputHelper; guestStayFacade = new GuestStayFacade(database, eventBus); - eventBus.Use(publishedEvents.Catch); + eventBus.Use(publishedMessages.Catch); } } diff --git a/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version2-ImmutableEntities/GuestStayAccounts/GuestStayAccount.cs b/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version2-ImmutableEntities/GuestStayAccounts/GuestStayAccount.cs index fe9dfa7e..d4b99bd9 100644 --- a/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version2-ImmutableEntities/GuestStayAccounts/GuestStayAccount.cs +++ b/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version2-ImmutableEntities/GuestStayAccounts/GuestStayAccount.cs @@ -70,36 +70,27 @@ public PaymentRecorded RecordPayment(decimal amount, DateTimeOffset now) return new PaymentRecorded(Id, amount, now); } - public (GuestCheckedOut?, GuestCheckoutFailed?) CheckOut(DateTimeOffset now, Guid? groupCheckoutId = null) + public GuestStayAccountEvent CheckOut(DateTimeOffset now, Guid? groupCheckoutId = null) { if (Status != GuestStayAccountStatus.Opened) - return ( - null, - new GuestCheckoutFailed( - Id, - GuestCheckoutFailed.FailureReason.NotOpened, - now, - groupCheckoutId - ) + return new GuestCheckoutFailed( + Id, + GuestCheckoutFailed.FailureReason.NotOpened, + now, + groupCheckoutId ); return IsSettled - ? ( - new GuestCheckedOut( - Id, - now, - groupCheckoutId - ), - null + ? new GuestCheckedOut( + Id, + now, + groupCheckoutId ) - : ( - null, - new GuestCheckoutFailed( - Id, - GuestCheckoutFailed.FailureReason.BalanceNotSettled, - now, - groupCheckoutId - ) + : new GuestCheckoutFailed( + Id, + GuestCheckoutFailed.FailureReason.BalanceNotSettled, + now, + groupCheckoutId ); } diff --git a/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version2-ImmutableEntities/GuestStayFacade.cs b/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version2-ImmutableEntities/GuestStayFacade.cs index d9cc1e03..839cfa66 100644 --- a/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version2-ImmutableEntities/GuestStayFacade.cs +++ b/Workshops/EventDrivenArchitecture/03-BusinessProcesses/Version2-ImmutableEntities/GuestStayFacade.cs @@ -1,11 +1,11 @@ using BusinessProcesses.Core; -using BusinessProcesses.Version2_ImmutableEntities.GroupCheckouts; using BusinessProcesses.Version2_ImmutableEntities.GuestStayAccounts; using GroupCheckoutEvent = BusinessProcesses.Version1_Aggregates.GroupCheckouts.GroupCheckoutEvent; namespace BusinessProcesses.Version2_ImmutableEntities; using static GuestStayAccountCommand; +using static GuestStayAccountEvent; using static GroupCheckoutCommand; public class GuestStayFacade(Database database, EventBus eventBus) @@ -47,13 +47,13 @@ public async ValueTask CheckOutGuest(CheckOutGuest command, CancellationToken ct switch (account.CheckOut(command.Now, command.GroupCheckOutId)) { - case ({ } checkedOut, _): + case GuestCheckedOut checkedOut: { await database.Store(command.GuestStayId, account.Evolve(checkedOut), ct); await eventBus.Publish([checkedOut], ct); return; } - case (_, { } checkOutFailed): + case GuestCheckoutFailed checkOutFailed: { await eventBus.Publish([checkOutFailed], ct); return; diff --git a/Workshops/EventDrivenArchitecture/Solutions/02-EntitiesDefinition/Solution2-ImmutableEntities/GuestStayAccounts/GuestStayAccount.cs b/Workshops/EventDrivenArchitecture/Solutions/02-EntitiesDefinition/Solution2-ImmutableEntities/GuestStayAccounts/GuestStayAccount.cs index 1761aaa1..b61f5dd5 100644 --- a/Workshops/EventDrivenArchitecture/Solutions/02-EntitiesDefinition/Solution2-ImmutableEntities/GuestStayAccounts/GuestStayAccount.cs +++ b/Workshops/EventDrivenArchitecture/Solutions/02-EntitiesDefinition/Solution2-ImmutableEntities/GuestStayAccounts/GuestStayAccount.cs @@ -70,36 +70,27 @@ public PaymentRecorded RecordPayment(decimal amount, DateTimeOffset now) return new PaymentRecorded(Id, amount, now); } - public (GuestCheckedOut?, GuestCheckoutFailed?) CheckOut(DateTimeOffset now, Guid? groupCheckoutId = null) + public GuestStayAccountEvent CheckOut(DateTimeOffset now, Guid? groupCheckoutId = null) { if (Status != GuestStayAccountStatus.Opened) - return ( - null, - new GuestCheckoutFailed( - Id, - GuestCheckoutFailed.FailureReason.NotOpened, - now, - groupCheckoutId - ) + return new GuestCheckoutFailed( + Id, + GuestCheckoutFailed.FailureReason.NotOpened, + now, + groupCheckoutId ); return IsSettled - ? ( - new GuestCheckedOut( - Id, - now, - groupCheckoutId - ), - null + ? new GuestCheckedOut( + Id, + now, + groupCheckoutId ) - : ( - null, - new GuestCheckoutFailed( - Id, - GuestCheckoutFailed.FailureReason.BalanceNotSettled, - now, - groupCheckoutId - ) + : new GuestCheckoutFailed( + Id, + GuestCheckoutFailed.FailureReason.BalanceNotSettled, + now, + groupCheckoutId ); } diff --git a/Workshops/EventDrivenArchitecture/Solutions/02-EntitiesDefinition/Solution2-ImmutableEntities/GuestStayFacade.cs b/Workshops/EventDrivenArchitecture/Solutions/02-EntitiesDefinition/Solution2-ImmutableEntities/GuestStayFacade.cs index 4288c6ba..81425e12 100644 --- a/Workshops/EventDrivenArchitecture/Solutions/02-EntitiesDefinition/Solution2-ImmutableEntities/GuestStayFacade.cs +++ b/Workshops/EventDrivenArchitecture/Solutions/02-EntitiesDefinition/Solution2-ImmutableEntities/GuestStayFacade.cs @@ -48,13 +48,13 @@ public async ValueTask CheckOutGuest(CheckOutGuest command, CancellationToken ct switch (account.CheckOut(command.Now, command.GroupCheckOutId)) { - case ({ } checkedOut, _): + case GuestCheckedOut checkedOut: { await database.Store(command.GuestStayId, account.Evolve(checkedOut), ct); await eventBus.Publish([checkedOut], ct); return; } - case (_, { } checkOutFailed): + case GuestStayAccountEvent.GuestCheckoutFailed checkOutFailed: { await eventBus.Publish([checkOutFailed], ct); return; diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Core/CommandBus.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Core/CommandBus.cs new file mode 100644 index 00000000..2ecd2e5a --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Core/CommandBus.cs @@ -0,0 +1,34 @@ +using System.Collections.Concurrent; +using FluentAssertions; + +namespace BusinessProcesses.Core; + +public class CommandBus +{ + public async ValueTask Send(object[] commands, CancellationToken ct) + { + foreach (var command in commands) + { + if (!commandHandlers.TryGetValue(command.GetType(), out var handler)) + continue; + + foreach (var middleware in middlewares) + middleware(command); + + await handler(command, ct); + } + } + + public CommandBus Handle(Func eventHandler) + { + commandHandlers[typeof(T)] = (command, ct) => eventHandler((T)command, ct); + + return this; + } + + public void Use(Action middleware) => + middlewares.Add(middleware); + + private readonly ConcurrentDictionary> commandHandlers = new(); + private readonly List> middlewares = []; +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Core/Database.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Core/Database.cs new file mode 100644 index 00000000..c83efac2 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Core/Database.cs @@ -0,0 +1,32 @@ +using System.Text.Json; + +namespace BusinessProcesses.Core; + +public class Database +{ + private readonly Dictionary storage = new(); + + public ValueTask Store(Guid id, T obj, CancellationToken _) where T : class + { + storage[GetId(id)] = obj; + + return ValueTask.CompletedTask; + } + + public ValueTask Delete(Guid id, CancellationToken _) + { + storage.Remove(GetId(id)); + return ValueTask.CompletedTask; + } + + public ValueTask Get(Guid id, CancellationToken _) where T : class => + ValueTask.FromResult( + storage.TryGetValue(GetId(id), out var result) + ? + // Clone to simulate getting new instance on loading + JsonSerializer.Deserialize(JsonSerializer.Serialize((T)result)) + : null + ); + + private static string GetId(Guid id) => $"{typeof(T).Name}-{id}"; +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Core/EventBus.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Core/EventBus.cs new file mode 100644 index 00000000..f700118e --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Core/EventBus.cs @@ -0,0 +1,44 @@ +using System.Collections.Concurrent; + +namespace BusinessProcesses.Core; + +public class EventBus +{ + public async ValueTask Publish(object[] events, CancellationToken ct) + { + foreach (var @event in events) + { + foreach (var middleware in middlewares) + middleware(@event); + + if (!eventHandlers.TryGetValue(@event.GetType(), out var handlers)) + continue; + + foreach (var handler in handlers) + await handler(@event, ct); + } + } + + public EventBus Subscribe(Func eventHandler) + { + Func handler = (@event, ct) => eventHandler((T)@event, ct); + + eventHandlers.AddOrUpdate( + typeof(T), + _ => [handler], + (_, handlers) => + { + handlers.Add(handler); + return handlers; + } + ); + + return this; + } + + public void Use(Action middleware) => + middlewares.Add(middleware); + + private readonly ConcurrentDictionary>> eventHandlers = new(); + private readonly List> middlewares = []; +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Core/MessageCatcher.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Core/MessageCatcher.cs new file mode 100644 index 00000000..94421948 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Core/MessageCatcher.cs @@ -0,0 +1,26 @@ +using FluentAssertions; + +namespace BusinessProcesses.Core; + +public class MessageCatcher +{ + public List Published { get; } = []; + + public void Catch(object message) => + Published.Add(message); + + public void Reset() => Published.Clear(); + + public void ShouldNotReceiveAnyMessage() => + Published.Should().BeEmpty(); + + public void ShouldReceiveSingleMessage(T message) + { + Published.Should().HaveCount(1); + Published.OfType().Should().HaveCount(1); + Published.Single().Should().Be(message); + } + + public void ShouldReceiveMessages(object[] messages) => + Published.Should().BeEquivalentTo(messages); +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/UnitTest1.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/UnitTest1.cs deleted file mode 100644 index 585496b3..00000000 --- a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/UnitTest1.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Xunit; - -namespace BusinessProcesses; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - } -} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/Core/Aggregate.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/Core/Aggregate.cs new file mode 100644 index 00000000..512723a7 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/Core/Aggregate.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace BusinessProcesses.Version1_Aggregates.Core; + +public abstract class Aggregate + where TEvent : class + where TId : notnull +{ + [JsonInclude] public TId Id { get; protected set; } = default!; + + [NonSerialized] private readonly Queue uncommittedEvents = new(); + + public virtual void Apply(TEvent @event) { } + + public object[] DequeueUncommittedEvents() + { + var dequeuedEvents = uncommittedEvents.Cast().ToArray();; + + uncommittedEvents.Clear(); + + return dequeuedEvents; + } + + protected void Enqueue(TEvent @event) + { + uncommittedEvents.Enqueue(@event); + Apply(@event); + } +} + diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/EntityDefinitionTests.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/EntityDefinitionTests.cs new file mode 100644 index 00000000..7a61426a --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/EntityDefinitionTests.cs @@ -0,0 +1,172 @@ +using Bogus; +using BusinessProcesses.Core; +using BusinessProcesses.Version1_Aggregates.GroupCheckouts; +using BusinessProcesses.Version1_Aggregates.GuestStayAccounts; +using Xunit; +using Xunit.Abstractions; +using Database = BusinessProcesses.Core.Database; + +namespace BusinessProcesses.Version1_Aggregates; + +using static GuestStayAccountEvent; +using static GuestStayAccountCommand; +using static GroupCheckoutCommand; + +public class EntityDefinitionTests +{ + [Fact] + public async Task CheckingInGuest_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + var command = new CheckInGuest(guestStayId, now); + publishedMessages.Reset(); + + // When + await guestStayFacade.CheckInGuest(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckedIn(guestStayId, now)); + } + + [Fact] + public async Task RecordingChargeForCheckedInGuest_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + publishedMessages.Reset(); + // And + var amount = generate.Finance.Amount(); + var command = new RecordCharge(guestStayId, amount, now); + + // When + await guestStayFacade.RecordCharge(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new ChargeRecorded(guestStayId, amount, now)); + } + + [Fact] + public async Task RecordingPaymentForCheckedInGuest_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + publishedMessages.Reset(); + // And + var amount = generate.Finance.Amount(); + var command = new RecordPayment(guestStayId, amount, now); + + // When + await guestStayFacade.RecordPayment(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new PaymentRecorded(guestStayId, amount, now)); + } + + [Fact] + public async Task RecordingPaymentForCheckedInGuestWithCharge_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStayId, generate.Finance.Amount(), now.AddHours(-1))); + publishedMessages.Reset(); + // And + var amount = generate.Finance.Amount(); + var command = new RecordPayment(guestStayId, amount, now); + + // When + await guestStayFacade.RecordPayment(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new PaymentRecorded(guestStayId, amount, now)); + } + + [Fact] + public async Task CheckingOutGuestWithSettledBalance_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + + var amount = generate.Finance.Amount(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStayId, amount, now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStayId, amount, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var command = new CheckOutGuest(guestStayId, now); + + // When + await guestStayFacade.CheckOutGuest(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckedOut(guestStayId, now)); + } + + [Fact] + public async Task CheckingOutGuestWithSettledBalance_FailsWithGuestCheckoutFailed() + { + // Given + var guestStayId = Guid.NewGuid(); + + var amount = generate.Finance.Amount(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStayId, amount + 10, now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStayId, amount, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var command = new CheckOutGuest(guestStayId, now); + + // When + try + { + await guestStayFacade.CheckOutGuest(command); + } + catch (Exception exc) + { + testOutputHelper.WriteLine(exc.Message); + } + + // Then + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckoutFailed(guestStayId, GuestCheckoutFailed.FailureReason.BalanceNotSettled, now)); + } + + [Fact] + public async Task GroupCheckoutForMultipleGuestStay_ShouldBeInitiated() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await guestStayFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now)); + } + + private readonly Database database = new(); + private readonly EventBus eventBus = new(); + private readonly MessageCatcher publishedMessages = new(); + private readonly GuestStayFacade guestStayFacade; + private readonly Faker generate = new(); + private readonly DateTimeOffset now = DateTimeOffset.Now; + private readonly ITestOutputHelper testOutputHelper; + + public EntityDefinitionTests(ITestOutputHelper testOutputHelper) + { + this.testOutputHelper = testOutputHelper; + guestStayFacade = new GuestStayFacade(database, eventBus); + eventBus.Use(publishedMessages.Catch); + } +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/GroupCheckouts/GroupCheckoutEvent.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/GroupCheckouts/GroupCheckoutEvent.cs new file mode 100644 index 00000000..e4904b9f --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/GroupCheckouts/GroupCheckoutEvent.cs @@ -0,0 +1,39 @@ +namespace BusinessProcesses.Version1_Aggregates.GroupCheckouts; + +public abstract record GroupCheckoutEvent +{ + public record GroupCheckoutInitiated( + Guid GroupCheckoutId, + Guid ClerkId, + Guid[] GuestStayIds, + DateTimeOffset InitiatedAt + ): GroupCheckoutEvent; + + public record GuestCheckoutCompleted( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset CompletedAt + ): GroupCheckoutEvent; + + public record GuestCheckoutFailed( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset FailedAt + ): GroupCheckoutEvent; + + public record GroupCheckoutCompleted( + Guid GroupCheckoutId, + Guid[] CompletedCheckouts, + DateTimeOffset CompletedAt + ): GroupCheckoutEvent; + + public record GroupCheckoutFailed( + Guid GroupCheckoutId, + Guid[] CompletedCheckouts, + Guid[] FailedCheckouts, + DateTimeOffset FailedAt + ): GroupCheckoutEvent; + + private GroupCheckoutEvent() { } +} + diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/GuestStayAccounts/GuestStayAccount.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/GuestStayAccounts/GuestStayAccount.cs new file mode 100644 index 00000000..6ae5d288 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/GuestStayAccounts/GuestStayAccount.cs @@ -0,0 +1,128 @@ +using System.Text.Json.Serialization; +using BusinessProcesses.Version1_Aggregates.Core; + +namespace BusinessProcesses.Version1_Aggregates.GuestStayAccounts; + +using static GuestStayAccountEvent; + +public abstract record GuestStayAccountEvent +{ + public record GuestCheckedIn( + Guid GuestStayId, + DateTimeOffset CheckedInAt + ): GuestStayAccountEvent; + + public record ChargeRecorded( + Guid GuestStayId, + decimal Amount, + DateTimeOffset RecordedAt + ): GuestStayAccountEvent; + + public record PaymentRecorded( + Guid GuestStayId, + decimal Amount, + DateTimeOffset RecordedAt + ): GuestStayAccountEvent; + + public record GuestCheckedOut( + Guid GuestStayId, + DateTimeOffset CheckedOutAt, + Guid? GroupCheckOutId = null + ): GuestStayAccountEvent; + + public record GuestCheckoutFailed( + Guid GuestStayId, + GuestCheckoutFailed.FailureReason Reason, + DateTimeOffset FailedAt, + Guid? GroupCheckOutId = null + ): GuestStayAccountEvent + { + public enum FailureReason + { + NotOpened, + BalanceNotSettled + } + } + + private GuestStayAccountEvent() { } +} + +public class GuestStayAccount: Aggregate +{ + [JsonInclude] private decimal balance; + [JsonInclude] private GuestStayAccountStatus status; + private bool IsSettled => balance == 0; + + [JsonConstructor] + private GuestStayAccount( + Guid id, + decimal balance, + GuestStayAccountStatus status + ) + { + Id = id; + this.balance = balance; + this.status = status; + } + + public static GuestStayAccount CheckIn(Guid guestStayId, DateTimeOffset now) + { + var guestStay = new GuestStayAccount(guestStayId, 0, GuestStayAccountStatus.Opened); + + guestStay.Enqueue(new GuestCheckedIn(guestStayId, now)); + + return guestStay; + } + + public void RecordCharge(decimal amount, DateTimeOffset now) + { + if (status != GuestStayAccountStatus.Opened) + throw new InvalidOperationException("Cannot record charge for not opened account"); + + balance -= amount; + + Enqueue(new ChargeRecorded(Id, amount, now)); + } + + public void RecordPayment(decimal amount, DateTimeOffset now) + { + if (status != GuestStayAccountStatus.Opened) + throw new InvalidOperationException("Cannot record charge for not opened account"); + + balance += amount; + + Enqueue(new PaymentRecorded(Id, amount, now)); + } + + public void CheckOut(DateTimeOffset now, Guid? groupCheckoutId = null) + { + if (status != GuestStayAccountStatus.Opened || !IsSettled) + { + Enqueue(new GuestCheckoutFailed( + Id, + status != GuestStayAccountStatus.Opened + ? GuestCheckoutFailed.FailureReason.NotOpened + : GuestCheckoutFailed.FailureReason.BalanceNotSettled, + now, + groupCheckoutId + )); + return; + } + + status = GuestStayAccountStatus.CheckedOut; + + Enqueue( + new GuestCheckedOut( + Id, + now, + groupCheckoutId + ) + ); + } +} + +public enum GuestStayAccountStatus +{ + Opened = 1, + CheckedOut = 2 +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/GuestStayFacade.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/GuestStayFacade.cs new file mode 100644 index 00000000..0b3da097 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version1-Aggregates/GuestStayFacade.cs @@ -0,0 +1,100 @@ +using BusinessProcesses.Core; +using BusinessProcesses.Version1_Aggregates.GroupCheckouts; +using BusinessProcesses.Version1_Aggregates.GuestStayAccounts; + +namespace BusinessProcesses.Version1_Aggregates; + +using static GuestStayAccountCommand; +using static GroupCheckoutCommand; + +public class GuestStayFacade(Database database, EventBus eventBus) +{ + public async ValueTask CheckInGuest(CheckInGuest command, CancellationToken ct = default) + { + var account = GuestStayAccount.CheckIn(command.GuestStayId, command.Now); + + await database.Store(command.GuestStayId, account, ct); + await eventBus.Publish(account.DequeueUncommittedEvents(), ct); + } + + public async ValueTask RecordCharge(RecordCharge command, CancellationToken ct = default) + { + var account = await database.Get(command.GuestStayId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + account.RecordCharge(command.Amount, command.Now); + + await database.Store(command.GuestStayId, account, ct); + await eventBus.Publish(account.DequeueUncommittedEvents(), ct); + } + + public async ValueTask RecordPayment(RecordPayment command, CancellationToken ct = default) + { + var account = await database.Get(command.GuestStayId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + account.RecordPayment(command.Amount, command.Now); + + await database.Store(command.GuestStayId, account, ct); + await eventBus.Publish(account.DequeueUncommittedEvents(), ct); + } + + public async ValueTask CheckOutGuest(CheckOutGuest command, CancellationToken ct = default) + { + var account = await database.Get(command.GuestStayId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + account.CheckOut(command.Now, command.GroupCheckOutId); + + await database.Store(command.GuestStayId, account, ct); + await eventBus.Publish(account.DequeueUncommittedEvents(), ct); + } + + public ValueTask InitiateGroupCheckout(InitiateGroupCheckout command, CancellationToken ct = default) => + eventBus.Publish([ + new GroupCheckoutEvent.GroupCheckoutInitiated( + command.GroupCheckoutId, + command.ClerkId, + command.GuestStayIds, + command.Now + ) + ], ct); +} + +public abstract record GuestStayAccountCommand +{ + public record CheckInGuest( + Guid GuestStayId, + DateTimeOffset Now + ); + + public record RecordCharge( + Guid GuestStayId, + decimal Amount, + DateTimeOffset Now + ); + + public record RecordPayment( + Guid GuestStayId, + decimal Amount, + DateTimeOffset Now + ); + + public record CheckOutGuest( + Guid GuestStayId, + DateTimeOffset Now, + Guid? GroupCheckOutId = null + ); +} + +public abstract record GroupCheckoutCommand +{ + public record InitiateGroupCheckout( + Guid GroupCheckoutId, + Guid ClerkId, + Guid[] GuestStayIds, + DateTimeOffset Now + ): GroupCheckoutCommand; + + private GroupCheckoutCommand() { } +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Core/DictionaryExtensions.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Core/DictionaryExtensions.cs new file mode 100644 index 00000000..e306006e --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Core/DictionaryExtensions.cs @@ -0,0 +1,17 @@ +namespace BusinessProcesses.Version2_ImmutableEntities.Core; + +public static class DictionaryExtensions +{ + public static Dictionary With( + this Dictionary first, + TKey key, + TValue value + ) where TKey : notnull + { + var newDictionary = first.ToDictionary(ks => ks.Key, vs => vs.Value); + + newDictionary[key] = value; + + return newDictionary; + } +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/BusinessProcessTests.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/BusinessProcessTests.cs new file mode 100644 index 00000000..b9050e8c --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/BusinessProcessTests.cs @@ -0,0 +1,244 @@ +using Bogus; +using BusinessProcesses.Core; +using BusinessProcesses.Version2_ImmutableEntities.Sagas.GroupCheckouts; +using BusinessProcesses.Version2_ImmutableEntities.Sagas.GuestStayAccounts; +using Xunit; +using Xunit.Abstractions; +using Database = BusinessProcesses.Core.Database; + +namespace BusinessProcesses.Version2_ImmutableEntities.Sagas; + +using static GuestStayAccountEvent; +using static GuestStayAccountCommand; +using static GroupCheckoutCommand; +using static GroupCheckoutsConfig; +using static GuestStayAccountsConfig; + +public class BusinessProcessTests +{ + [Fact] + public async Task GroupCheckoutForMultipleGuestStayWithoutPaymentsAndCharges_ShouldComplete() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await groupCheckoutFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveMessages( + [ + new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), + new CheckOutGuest(guestStays[0], now, groupCheckoutId), + new GuestCheckedOut(guestStays[0], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[0], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now), + new CheckOutGuest(guestStays[1], now, groupCheckoutId), + new GuestCheckedOut(guestStays[1], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[1], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[1], now), + new CheckOutGuest(guestStays[2], now, groupCheckoutId), + new GuestCheckedOut(guestStays[2], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GroupCheckoutCompleted(groupCheckoutId, guestStays, now), + ] + ); + } + + [Fact] + public async Task GroupCheckoutForMultipleGuestStayWithAllStaysSettled_ShouldComplete() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + decimal[] amounts = [generate.Finance.Amount(), generate.Finance.Amount(), generate.Finance.Amount()]; + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[0], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[0], now.AddHours(-1))); + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[1], amounts[1], now.AddHours(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[1], amounts[1], now.AddHours(-2))); + + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[2], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[2] / 2, now.AddHours(-1))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[2] / 2, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await groupCheckoutFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveMessages( + [ + new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), + new CheckOutGuest(guestStays[0], now, groupCheckoutId), + new GuestCheckedOut(guestStays[0], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[0], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now), + new CheckOutGuest(guestStays[1], now, groupCheckoutId), + new GuestCheckedOut(guestStays[1], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[1], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[1], now), + new CheckOutGuest(guestStays[2], now, groupCheckoutId), + new GuestCheckedOut(guestStays[2], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GroupCheckoutCompleted(groupCheckoutId, guestStays, now), + ] + ); + } + + [Fact] + public async Task GroupCheckoutForMultipleGuestStayWithOneSettledAndRestUnsettled_ShouldFail() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + decimal[] amounts = [generate.Finance.Amount(), generate.Finance.Amount(), generate.Finance.Amount()]; + + // 🟢 settled + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[0], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[0], amounts[0], now.AddHours(-1))); + + // 🛑 payment without charge + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[1], amounts[1], now.AddHours(-1))); + + // 🛑 payment without charge + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[2], amounts[2], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[2], amounts[2] / 2, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await groupCheckoutFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveMessages( + [ + new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), + new CheckOutGuest(guestStays[0], now, groupCheckoutId), + new GuestCheckedOut(guestStays[0], now, groupCheckoutId), + new RecordGuestCheckoutCompletion(groupCheckoutId, guestStays[0], now), + new GroupCheckoutEvent.GuestCheckoutCompleted(groupCheckoutId, guestStays[0], now), + new CheckOutGuest(guestStays[1], now, groupCheckoutId), + new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[1], now), + new GuestCheckoutFailed(guestStays[1], GuestCheckoutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[1], now), + new CheckOutGuest(guestStays[2], now, groupCheckoutId), + new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[2], now), + new GuestCheckoutFailed(guestStays[2], GuestCheckoutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GroupCheckoutFailed( + groupCheckoutId, + [guestStays[0]], + [guestStays[1], guestStays[2]], + now + ), + ] + ); + } + + + [Fact] + public async Task GroupCheckoutForMultipleGuestStayWithAllUnsettled_ShouldFail() + { + // Given; + Guid[] guestStays = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()]; + decimal[] amounts = [generate.Finance.Amount(), generate.Finance.Amount(), generate.Finance.Amount()]; + + // 🛑 charge without payment + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[0], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[0], amounts[0], now.AddHours(-2))); + + // 🛑 payment without charge + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[1], now.AddDays(-1))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[1], amounts[1], now.AddHours(-1))); + + // 🛑 payment without charge + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStays[2], now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStays[2], amounts[2], now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStays[2], amounts[2] / 2, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var groupCheckoutId = Guid.NewGuid(); + var clerkId = Guid.NewGuid(); + var command = new InitiateGroupCheckout(groupCheckoutId, clerkId, guestStays, now); + + // When + await groupCheckoutFacade.InitiateGroupCheckout(command); + + // Then + publishedMessages.ShouldReceiveMessages( + [ + new GroupCheckoutEvent.GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStays, now), + new CheckOutGuest(guestStays[0], now, groupCheckoutId), + new GuestCheckoutFailed(guestStays[0], GuestCheckoutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[0], now), + new GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[0], now), + new CheckOutGuest(guestStays[1], now, groupCheckoutId), + new GuestCheckoutFailed(guestStays[1], GuestCheckoutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[1], now), + new GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[1], now), + new CheckOutGuest(guestStays[2], now, groupCheckoutId), + new GuestCheckoutFailed(guestStays[2], GuestCheckoutFailed.FailureReason.BalanceNotSettled, now, + groupCheckoutId), + new RecordGuestCheckoutFailure(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GuestCheckoutFailed(groupCheckoutId, guestStays[2], now), + new GroupCheckoutEvent.GroupCheckoutFailed( + groupCheckoutId, + [], + [guestStays[0], guestStays[1], guestStays[2]], + now + ), + ] + ); + } + + private readonly Database database = new(); + private readonly EventBus eventBus = new(); + private readonly CommandBus commandBus = new(); + private readonly MessageCatcher publishedMessages = new(); + private readonly GuestStayFacade guestStayFacade; + private readonly GroupCheckoutFacade groupCheckoutFacade; + private readonly Faker generate = new(); + private readonly DateTimeOffset now = DateTimeOffset.Now; + private readonly ITestOutputHelper testOutputHelper; + + public BusinessProcessTests(ITestOutputHelper testOutputHelper) + { + this.testOutputHelper = testOutputHelper; + guestStayFacade = new GuestStayFacade(database, eventBus); + groupCheckoutFacade = new GroupCheckoutFacade(database, eventBus); + + eventBus.Use(publishedMessages.Catch); + commandBus.Use(publishedMessages.Catch); + + ConfigureGroupCheckouts(eventBus, commandBus, groupCheckoutFacade); + ConfigureGuestStayAccounts(commandBus, guestStayFacade); + } +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/EntityDefinitionTests.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/EntityDefinitionTests.cs new file mode 100644 index 00000000..508c75fb --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/EntityDefinitionTests.cs @@ -0,0 +1,148 @@ +using Bogus; +using BusinessProcesses.Core; +using BusinessProcesses.Version2_ImmutableEntities.Sagas.GuestStayAccounts; +using Xunit; +using Xunit.Abstractions; +using Database = BusinessProcesses.Core.Database; + +namespace BusinessProcesses.Version2_ImmutableEntities.Sagas; + +using static GuestStayAccountEvent; +using static GuestStayAccountCommand; + +public class EntityDefinitionTests +{ + [Fact] + public async Task CheckingInGuest_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + var command = new CheckInGuest(guestStayId, now); + publishedMessages.Reset(); + + // When + await guestStayFacade.CheckInGuest(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckedIn(guestStayId, now)); + } + + [Fact] + public async Task RecordingChargeForCheckedInGuest_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + publishedMessages.Reset(); + // And + var amount = generate.Finance.Amount(); + var command = new RecordCharge(guestStayId, amount, now); + + // When + await guestStayFacade.RecordCharge(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new ChargeRecorded(guestStayId, amount, now)); + } + + [Fact] + public async Task RecordingPaymentForCheckedInGuest_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + publishedMessages.Reset(); + // And + var amount = generate.Finance.Amount(); + var command = new RecordPayment(guestStayId, amount, now); + + // When + await guestStayFacade.RecordPayment(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new PaymentRecorded(guestStayId, amount, now)); + } + + [Fact] + public async Task RecordingPaymentForCheckedInGuestWithCharge_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStayId, generate.Finance.Amount(), now.AddHours(-1))); + publishedMessages.Reset(); + // And + var amount = generate.Finance.Amount(); + var command = new RecordPayment(guestStayId, amount, now); + + // When + await guestStayFacade.RecordPayment(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new PaymentRecorded(guestStayId, amount, now)); + } + + [Fact] + public async Task CheckingOutGuestWithSettledBalance_Succeeds() + { + // Given + var guestStayId = Guid.NewGuid(); + + var amount = generate.Finance.Amount(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStayId, amount, now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStayId, amount, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var command = new CheckOutGuest(guestStayId, now); + + // When + await guestStayFacade.CheckOutGuest(command); + + // Then + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckedOut(guestStayId, now)); + } + + [Fact] + public async Task CheckingOutGuestWithSettledBalance_FailsWithGuestCheckoutFailed() + { + // Given + var guestStayId = Guid.NewGuid(); + + var amount = generate.Finance.Amount(); + await guestStayFacade.CheckInGuest(new CheckInGuest(guestStayId, now.AddDays(-1))); + await guestStayFacade.RecordCharge(new RecordCharge(guestStayId, amount + 10, now.AddHours(-2))); + await guestStayFacade.RecordPayment(new RecordPayment(guestStayId, amount, now.AddHours(-1))); + publishedMessages.Reset(); + // And + var command = new CheckOutGuest(guestStayId, now); + + // When + try + { + await guestStayFacade.CheckOutGuest(command); + } + catch (Exception exc) + { + testOutputHelper.WriteLine(exc.Message); + } + + // Then + publishedMessages.ShouldReceiveSingleMessage(new GuestCheckoutFailed(guestStayId, GuestCheckoutFailed.FailureReason.BalanceNotSettled, now)); + } + + private readonly Database database = new(); + private readonly EventBus eventBus = new(); + private readonly MessageCatcher publishedMessages = new(); + private readonly GuestStayFacade guestStayFacade; + private readonly Faker generate = new(); + private readonly DateTimeOffset now = DateTimeOffset.Now; + private readonly ITestOutputHelper testOutputHelper; + + public EntityDefinitionTests(ITestOutputHelper testOutputHelper) + { + this.testOutputHelper = testOutputHelper; + guestStayFacade = new GuestStayFacade(database, eventBus); + eventBus.Use(publishedMessages.Catch); + } +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GroupCheckouts/GroupCheckout.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GroupCheckouts/GroupCheckout.cs new file mode 100644 index 00000000..26e83dcd --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GroupCheckouts/GroupCheckout.cs @@ -0,0 +1,156 @@ +using BusinessProcesses.Version2_ImmutableEntities.Core; + +namespace BusinessProcesses.Version2_ImmutableEntities.Sagas.GroupCheckouts; + +using static GroupCheckoutEvent; + +public abstract record GroupCheckoutEvent +{ + public record GroupCheckoutInitiated( + Guid GroupCheckoutId, + Guid ClerkId, + Guid[] GuestStayIds, + DateTimeOffset InitiatedAt + ): GroupCheckoutEvent; + + public record GuestCheckoutCompleted( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset CompletedAt + ): GroupCheckoutEvent; + + public record GuestCheckoutFailed( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset FailedAt + ): GroupCheckoutEvent; + + public record GroupCheckoutCompleted( + Guid GroupCheckoutId, + Guid[] CompletedCheckouts, + DateTimeOffset CompletedAt + ): GroupCheckoutEvent; + + public record GroupCheckoutFailed( + Guid GroupCheckoutId, + Guid[] CompletedCheckouts, + Guid[] FailedCheckouts, + DateTimeOffset FailedAt + ): GroupCheckoutEvent; + + private GroupCheckoutEvent() { } +} + +public record GroupCheckout( + Guid Id, + Dictionary GuestStayCheckouts, + CheckoutStatus Status = CheckoutStatus.Initiated +) +{ + public static GroupCheckoutInitiated Initiate(Guid groupCheckoutId, Guid clerkId, Guid[] guestStayIds, + DateTimeOffset initiatedAt) => + new(groupCheckoutId, clerkId, guestStayIds, initiatedAt); + + public GroupCheckoutEvent[] RecordGuestCheckoutCompletion( + Guid guestStayId, + DateTimeOffset now + ) + { + if (Status != CheckoutStatus.Initiated || GuestStayCheckouts[guestStayId] == CheckoutStatus.Completed) + return []; + + var guestCheckoutCompleted = new GuestCheckoutCompleted(Id, guestStayId, now); + + var guestStayCheckouts = GuestStayCheckouts.With(guestStayId, CheckoutStatus.Completed); + + return AreAnyOngoingCheckouts(guestStayCheckouts) + ? [guestCheckoutCompleted] + : [guestCheckoutCompleted, Finalize(guestStayCheckouts, now)]; + } + + public GroupCheckoutEvent[] RecordGuestCheckoutFailure( + Guid guestStayId, + DateTimeOffset now + ) + { + if (Status != CheckoutStatus.Initiated || GuestStayCheckouts[guestStayId] == CheckoutStatus.Failed) + return []; + + var guestCheckoutFailed = new GuestCheckoutFailed(Id, guestStayId, now); + + var guestStayCheckouts = GuestStayCheckouts.With(guestStayId, CheckoutStatus.Failed); + + return AreAnyOngoingCheckouts(guestStayCheckouts) + ? [guestCheckoutFailed] + : [guestCheckoutFailed, Finalize(guestStayCheckouts, now)]; + } + + private GroupCheckoutEvent Finalize( + Dictionary guestStayCheckouts, + DateTimeOffset now + ) => + !AreAnyFailedCheckouts(guestStayCheckouts) + ? new GroupCheckoutCompleted + ( + Id, + CheckoutsWith(guestStayCheckouts, CheckoutStatus.Completed), + now + ) + : new GroupCheckoutFailed + ( + Id, + CheckoutsWith(guestStayCheckouts, CheckoutStatus.Completed), + CheckoutsWith(guestStayCheckouts, CheckoutStatus.Failed), + now + ); + + private static bool AreAnyOngoingCheckouts(Dictionary guestStayCheckouts) => + guestStayCheckouts.Values.Any(status => status is CheckoutStatus.Initiated); + + private static bool AreAnyFailedCheckouts(Dictionary guestStayCheckouts) => + guestStayCheckouts.Values.Any(status => status is CheckoutStatus.Failed); + + private static Guid[] CheckoutsWith(Dictionary guestStayCheckouts, CheckoutStatus status) => + guestStayCheckouts + .Where(pair => pair.Value == status) + .Select(pair => pair.Key) + .ToArray(); + + + public GroupCheckout Evolve(GroupCheckoutEvent @event) => + @event switch + { + GroupCheckoutInitiated initiated => this with + { + Id = initiated.GroupCheckoutId, + GuestStayCheckouts = initiated.GuestStayIds.ToDictionary(id => id, _ => CheckoutStatus.Initiated), + Status = CheckoutStatus.Initiated + }, + GuestCheckoutCompleted guestCheckedOut => this with + { + GuestStayCheckouts = GuestStayCheckouts.ToDictionary( + ks => ks.Key, + vs => vs.Key == guestCheckedOut.GuestStayId ? CheckoutStatus.Completed : vs.Value + ) + }, + GuestCheckoutFailed guestCheckedOutFailed => this with + { + GuestStayCheckouts = GuestStayCheckouts.ToDictionary( + ks => ks.Key, + vs => vs.Key == guestCheckedOutFailed.GuestStayId ? CheckoutStatus.Failed : vs.Value + ) + }, + GroupCheckoutCompleted => this with { Status = CheckoutStatus.Completed }, + GroupCheckoutFailed => this with { Status = CheckoutStatus.Failed }, + _ => this + }; + + public static GroupCheckout Initial = new(default, [], default); +} + +public enum CheckoutStatus +{ + Initiated, + Completed, + Failed +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GroupCheckouts/GroupCheckoutFacade.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GroupCheckouts/GroupCheckoutFacade.cs new file mode 100644 index 00000000..2a60713b --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GroupCheckouts/GroupCheckoutFacade.cs @@ -0,0 +1,82 @@ +using BusinessProcesses.Core; +using Database = BusinessProcesses.Core.Database; + +namespace BusinessProcesses.Version2_ImmutableEntities.Sagas.GroupCheckouts; + +using static GroupCheckoutCommand; + +public class GroupCheckoutFacade(Database database, EventBus eventBus) +{ + public async ValueTask InitiateGroupCheckout(InitiateGroupCheckout command, CancellationToken ct = default) + { + var @event = + new GroupCheckoutEvent.GroupCheckoutInitiated(command.GroupCheckoutId, command.ClerkId, + command.GuestStayIds, command.Now); + + await database.Store(command.GroupCheckoutId, GroupCheckout.Initial.Evolve(@event), ct); + await eventBus.Publish([@event], ct); + } + + public async ValueTask RecordGuestCheckoutCompletion( + RecordGuestCheckoutCompletion command, + CancellationToken ct = default + ) + { + var groupCheckout = await database.Get(command.GroupCheckoutId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + var events = groupCheckout.RecordGuestCheckoutCompletion(command.GuestStayId, command.CompletedAt); + + if (events.Length == 0) + return; + + await database.Store(command.GroupCheckoutId, + events.Aggregate(groupCheckout, (state, @event) => state.Evolve(@event)), ct); + + await eventBus.Publish(events.Cast().ToArray(), ct); + } + + public async ValueTask RecordGuestCheckoutFailure( + RecordGuestCheckoutFailure command, + CancellationToken ct = default + ) + { + var groupCheckout = await database.Get(command.GroupCheckoutId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + var events = groupCheckout.RecordGuestCheckoutFailure(command.GuestStayId, command.FailedAt); + + if (events.Length == 0) + return; + + var newState = events.Aggregate(groupCheckout, (state, @event) => state.Evolve(@event)); + + await database.Store(command.GroupCheckoutId, newState, ct); + + await eventBus.Publish(events.Cast().ToArray(), ct); + } +} + +public abstract record GroupCheckoutCommand +{ + public record InitiateGroupCheckout( + Guid GroupCheckoutId, + Guid ClerkId, + Guid[] GuestStayIds, + DateTimeOffset Now + ): GroupCheckoutCommand; + + public record RecordGuestCheckoutCompletion( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset CompletedAt + ): GroupCheckoutCommand; + + public record RecordGuestCheckoutFailure( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset FailedAt + ): GroupCheckoutCommand; + + private GroupCheckoutCommand() { } +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GroupCheckouts/GroupCheckoutSaga.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GroupCheckouts/GroupCheckoutSaga.cs new file mode 100644 index 00000000..b0836c86 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GroupCheckouts/GroupCheckoutSaga.cs @@ -0,0 +1,60 @@ +using BusinessProcesses.Version2_ImmutableEntities.Sagas.GuestStayAccounts; + +namespace BusinessProcesses.Version2_ImmutableEntities.Sagas.GroupCheckouts; + +using static GroupCheckoutCommand; +using static GroupCheckoutEvent; +using static GuestStayAccountCommand; +using static GuestStayAccountEvent; +using static SagaResult; + +public static class GroupCheckoutSaga +{ + public static Command[] Handle(GroupCheckoutInitiated @event) => + @event.GuestStayIds.Select(guestAccountId => + Send(new CheckOutGuest(guestAccountId, @event.InitiatedAt, @event.GroupCheckoutId)) + ).ToArray(); + + public static SagaResult Handle(GuestCheckedOut @event) + { + if (!@event.GroupCheckOutId.HasValue) + return Ignore; + + return Send( + new RecordGuestCheckoutCompletion( + @event.GroupCheckOutId.Value, + @event.GuestStayId, + @event.CheckedOutAt + ) + ); + } + + public static SagaResult Handle(GuestStayAccountEvent.GuestCheckoutFailed @event) + { + if (!@event.GroupCheckOutId.HasValue) + return Ignore; + + return Send( + new RecordGuestCheckoutFailure( + @event.GroupCheckOutId.Value, + @event.GuestStayId, + @event.FailedAt + ) + ); + } +}; + +public abstract record SagaResult +{ + public record Command(T Message): SagaResult; + + public record Event(T Message): SagaResult; + + public record None: SagaResult; + + public static Command Send(T command) => new(command); + + public static Event Publish(T @event) => new(@event); + + public static readonly None Ignore = new(); +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GroupCheckouts/GroupCheckoutsConfig.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GroupCheckouts/GroupCheckoutsConfig.cs new file mode 100644 index 00000000..f03eaff0 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GroupCheckouts/GroupCheckoutsConfig.cs @@ -0,0 +1,36 @@ +using BusinessProcesses.Core; +using BusinessProcesses.Version2_ImmutableEntities.Sagas.GuestStayAccounts; + +namespace BusinessProcesses.Version2_ImmutableEntities.Sagas.GroupCheckouts; + +using static GuestStayAccountEvent; +using static GroupCheckoutCommand; +using static SagaResult; + +public static class GroupCheckoutsConfig +{ + public static void ConfigureGroupCheckouts( + EventBus eventBus, + CommandBus commandBus, + GroupCheckoutFacade groupCheckoutFacade + ) + { + eventBus + .Subscribe((@event, ct) => + commandBus.Send(GroupCheckoutSaga.Handle(@event).Select(c => c.Message).ToArray(), ct) + ) + .Subscribe((@event, ct) => + GroupCheckoutSaga.Handle(@event) is Command(var command) + ? commandBus.Send([command], ct) + : ValueTask.CompletedTask + ) + .Subscribe((@event, ct) => + GroupCheckoutSaga.Handle(@event) is Command(var command) + ? commandBus.Send([command], ct) + : ValueTask.CompletedTask + ); + + commandBus.Handle(groupCheckoutFacade.RecordGuestCheckoutCompletion); + commandBus.Handle(groupCheckoutFacade.RecordGuestCheckoutFailure); + } +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GuestStayAccounts/GroupCheckoutsConfig.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GuestStayAccounts/GroupCheckoutsConfig.cs new file mode 100644 index 00000000..32692215 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GuestStayAccounts/GroupCheckoutsConfig.cs @@ -0,0 +1,18 @@ +using BusinessProcesses.Core; +using BusinessProcesses.Version2_ImmutableEntities.Sagas.GroupCheckouts; +using BusinessProcesses.Version2_ImmutableEntities.Sagas.GuestStayAccounts; + +namespace BusinessProcesses.Version2_ImmutableEntities.Sagas.GuestStayAccounts; + +using static GuestStayAccountCommand; + +public static class GuestStayAccountsConfig +{ + public static void ConfigureGuestStayAccounts( + CommandBus commandBus, + GuestStayFacade guestStayFacade + ) + { + commandBus.Handle(guestStayFacade.CheckOutGuest); + } +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GuestStayAccounts/GuestStayAccount.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GuestStayAccounts/GuestStayAccount.cs new file mode 100644 index 00000000..7d989da8 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GuestStayAccounts/GuestStayAccount.cs @@ -0,0 +1,120 @@ +namespace BusinessProcesses.Version2_ImmutableEntities.Sagas.GuestStayAccounts; + +using static GuestStayAccountEvent; + +public abstract record GuestStayAccountEvent +{ + public record GuestCheckedIn( + Guid GuestStayId, + DateTimeOffset CheckedInAt + ): GuestStayAccountEvent; + + public record ChargeRecorded( + Guid GuestStayId, + decimal Amount, + DateTimeOffset RecordedAt + ): GuestStayAccountEvent; + + public record PaymentRecorded( + Guid GuestStayId, + decimal Amount, + DateTimeOffset RecordedAt + ): GuestStayAccountEvent; + + public record GuestCheckedOut( + Guid GuestStayId, + DateTimeOffset CheckedOutAt, + Guid? GroupCheckOutId = null + ): GuestStayAccountEvent; + + public record GuestCheckoutFailed( + Guid GuestStayId, + GuestCheckoutFailed.FailureReason Reason, + DateTimeOffset FailedAt, + Guid? GroupCheckOutId = null + ): GuestStayAccountEvent + { + public enum FailureReason + { + NotOpened, + BalanceNotSettled + } + } + + private GuestStayAccountEvent() { } +} + +public record GuestStayAccount( + Guid Id, + decimal Balance = 0, + GuestStayAccountStatus Status = GuestStayAccountStatus.Opened +) +{ + public bool IsSettled => Balance == 0; + + public static GuestCheckedIn CheckIn(Guid guestStayId, DateTimeOffset now) => new(guestStayId, now); + + public ChargeRecorded RecordCharge(decimal amount, DateTimeOffset now) + { + if (Status != GuestStayAccountStatus.Opened) + throw new InvalidOperationException("Cannot record charge for not opened account"); + + return new ChargeRecorded(Id, amount, now); + } + + public PaymentRecorded RecordPayment(decimal amount, DateTimeOffset now) + { + if (Status != GuestStayAccountStatus.Opened) + throw new InvalidOperationException("Cannot record charge for not opened account"); + + return new PaymentRecorded(Id, amount, now); + } + + public GuestStayAccountEvent CheckOut(DateTimeOffset now, Guid? groupCheckoutId = null) + { + if (Status != GuestStayAccountStatus.Opened) + return new GuestCheckoutFailed( + Id, + GuestCheckoutFailed.FailureReason.NotOpened, + now, + groupCheckoutId + ); + + return IsSettled + ? new GuestCheckedOut( + Id, + now, + groupCheckoutId + ) + : new GuestCheckoutFailed( + Id, + GuestCheckoutFailed.FailureReason.BalanceNotSettled, + now, + groupCheckoutId + ); + } + + // This method can be used to build state from events + // You can ignore it if you're not into Event Sourcing + public GuestStayAccount Evolve(GuestStayAccountEvent @event) => + @event switch + { + GuestCheckedIn checkedIn => this with + { + Id = checkedIn.GuestStayId, Status = GuestStayAccountStatus.Opened + }, + ChargeRecorded charge => this with { Balance = Balance - charge.Amount }, + PaymentRecorded payment => this with { Balance = Balance + payment.Amount }, + GuestCheckedOut => this with { Status = GuestStayAccountStatus.CheckedOut }, + GuestCheckoutFailed => this, + _ => this + }; + + public static readonly GuestStayAccount Initial = new(default, default, default); +} + +public enum GuestStayAccountStatus +{ + Opened = 1, + CheckedOut = 2 +} diff --git a/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GuestStayAccounts/GuestStayFacade.cs b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GuestStayAccounts/GuestStayFacade.cs new file mode 100644 index 00000000..3a59e9b5 --- /dev/null +++ b/Workshops/EventDrivenArchitecture/Solutions/03-BusinessProcesses/Version2-ImmutableEntities/Sagas/GuestStayAccounts/GuestStayFacade.cs @@ -0,0 +1,86 @@ +using BusinessProcesses.Core; + +namespace BusinessProcesses.Version2_ImmutableEntities.Sagas.GuestStayAccounts; + +using static GuestStayAccountCommand; +using static GuestStayAccountEvent; + +public class GuestStayFacade(Database database, EventBus eventBus) +{ + public async ValueTask CheckInGuest(CheckInGuest command, CancellationToken ct = default) + { + var @event = GuestStayAccount.CheckIn(command.GuestStayId, command.Now); + + await database.Store(command.GuestStayId, GuestStayAccount.Initial.Evolve(@event), ct); + await eventBus.Publish([@event], ct); + } + + public async ValueTask RecordCharge(RecordCharge command, CancellationToken ct = default) + { + var account = await database.Get(command.GuestStayId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + var @event = account.RecordCharge(command.Amount, command.Now); + + await database.Store(command.GuestStayId, account.Evolve(@event), ct); + await eventBus.Publish([@event], ct); + } + + public async ValueTask RecordPayment(RecordPayment command, CancellationToken ct = default) + { + var account = await database.Get(command.GuestStayId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + var @event = account.RecordPayment(command.Amount, command.Now); + + await database.Store(command.GuestStayId, account.Evolve(@event), ct); + await eventBus.Publish([@event], ct); + } + + public async ValueTask CheckOutGuest(CheckOutGuest command, CancellationToken ct = default) + { + var account = await database.Get(command.GuestStayId, ct) + ?? throw new InvalidOperationException("Entity not found"); + + switch (account.CheckOut(command.Now, command.GroupCheckOutId)) + { + case GuestCheckedOut checkedOut: + { + await database.Store(command.GuestStayId, account.Evolve(checkedOut), ct); + await eventBus.Publish([checkedOut], ct); + return; + } + case GuestCheckoutFailed checkOutFailed: + { + await eventBus.Publish([checkOutFailed], ct); + return; + } + } + } +} + +public abstract record GuestStayAccountCommand +{ + public record CheckInGuest( + Guid GuestStayId, + DateTimeOffset Now + ); + + public record RecordCharge( + Guid GuestStayId, + decimal Amount, + DateTimeOffset Now + ); + + public record RecordPayment( + Guid GuestStayId, + decimal Amount, + DateTimeOffset Now + ); + + public record CheckOutGuest( + Guid GuestStayId, + DateTimeOffset Now, + Guid? GroupCheckOutId = null + ); +}