Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Added example Saga implementation
Browse files Browse the repository at this point in the history
oskardudycz committed Sep 3, 2024
1 parent 7dc4043 commit 4f57da6
Showing 32 changed files with 1,894 additions and 128 deletions.
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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<T>(Action<T> eventHandler)
public void Handle<T>(Func<T, CancellationToken, ValueTask> eventHandler)
{
commandHandlers[typeof(T)] = x => eventHandler((T)x);
commandHandlers[typeof(T)] = (command, ct) => eventHandler((T)command, ct);
}

public void Use(Action<object> middleware) =>
middlewares.Add(middleware);

private readonly ConcurrentDictionary<Type, Action<object>> commandHandlers = new();
private readonly ConcurrentDictionary<Type, Func<object, CancellationToken, ValueTask>> commandHandlers = new();
private readonly List<Action<object>> middlewares = [];
}
Original file line number Diff line number Diff line change
@@ -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<T>(Action<T> eventHandler)
public void Subscribe<T>(Func<T, CancellationToken, ValueTask> eventHandler)
{
Action<object> handler = x => eventHandler((T)x);
Func<object, CancellationToken, ValueTask> handler = (@event, ct) => eventHandler((T)@event, ct);

eventHandlers.AddOrUpdate(
typeof(T),
@@ -39,6 +37,6 @@ public void Subscribe<T>(Action<T> eventHandler)
public void Use(Action<object> middleware) =>
middlewares.Add(middleware);

private readonly ConcurrentDictionary<Type, List<Action<object>>> eventHandlers = new();
private readonly ConcurrentDictionary<Type, List<Func<object, CancellationToken, ValueTask>>> eventHandlers = new();
private readonly List<Action<object>> middlewares = [];
}
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@

namespace BusinessProcesses.Core;

public class EventCatcher
public class MessageCatcher
{
public List<object> Published { get; } = [];

Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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,15 +94,15 @@ 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);

// When
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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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,15 +94,15 @@ 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);

// When
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);
}
}
Original file line number Diff line number Diff line change
@@ -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
);
}

Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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
);
}

Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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<T>(Func<T, CancellationToken, ValueTask> eventHandler)
{
commandHandlers[typeof(T)] = (command, ct) => eventHandler((T)command, ct);

return this;
}

public void Use(Action<object> middleware) =>
middlewares.Add(middleware);

private readonly ConcurrentDictionary<Type, Func<object, CancellationToken, ValueTask>> commandHandlers = new();
private readonly List<Action<object>> middlewares = [];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.Text.Json;

namespace BusinessProcesses.Core;

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

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

return ValueTask.CompletedTask;
}

public ValueTask Delete<T>(Guid id, CancellationToken _)
{
storage.Remove(GetId<T>(id));
return ValueTask.CompletedTask;
}

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

private static string GetId<T>(Guid id) => $"{typeof(T).Name}-{id}";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,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<T>(Func<T, CancellationToken, ValueTask> eventHandler)
{
Func<object, CancellationToken, ValueTask> handler = (@event, ct) => eventHandler((T)@event, ct);

eventHandlers.AddOrUpdate(
typeof(T),
_ => [handler],
(_, handlers) =>
{
handlers.Add(handler);
return handlers;
}
);

return this;
}

public void Use(Action<object> middleware) =>
middlewares.Add(middleware);

private readonly ConcurrentDictionary<Type, List<Func<object, CancellationToken, ValueTask>>> eventHandlers = new();
private readonly List<Action<object>> middlewares = [];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using FluentAssertions;

namespace BusinessProcesses.Core;

public class MessageCatcher
{
public List<object> 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>(T message)
{
Published.Should().HaveCount(1);
Published.OfType<T>().Should().HaveCount(1);
Published.Single().Should().Be(message);
}

public void ShouldReceiveMessages(object[] messages) =>
Published.Should().BeEquivalentTo(messages);
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.Text.Json.Serialization;

namespace BusinessProcesses.Version1_Aggregates.Core;

public abstract class Aggregate<TEvent, TId>
where TEvent : class
where TId : notnull
{
[JsonInclude] public TId Id { get; protected set; } = default!;

[NonSerialized] private readonly Queue<TEvent> uncommittedEvents = new();

public virtual void Apply(TEvent @event) { }

public object[] DequeueUncommittedEvents()
{
var dequeuedEvents = uncommittedEvents.Cast<object>().ToArray();;

uncommittedEvents.Clear();

return dequeuedEvents;
}

protected void Enqueue(TEvent @event)
{
uncommittedEvents.Enqueue(@event);
Apply(@event);
}
}

Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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() { }
}

Original file line number Diff line number Diff line change
@@ -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<GuestStayAccountEvent, Guid>
{
[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
}
Original file line number Diff line number Diff line change
@@ -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<GuestStayAccount>(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<GuestStayAccount>(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<GuestStayAccount>(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() { }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace BusinessProcesses.Version2_ImmutableEntities.Core;

public static class DictionaryExtensions
{
public static Dictionary<TKey, TValue> With<TKey, TValue>(
this Dictionary<TKey, TValue> first,
TKey key,
TValue value
) where TKey : notnull
{
var newDictionary = first.ToDictionary(ks => ks.Key, vs => vs.Value);

newDictionary[key] = value;

return newDictionary;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<Guid, CheckoutStatus> 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<Guid, CheckoutStatus> 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<Guid, CheckoutStatus> guestStayCheckouts) =>
guestStayCheckouts.Values.Any(status => status is CheckoutStatus.Initiated);

private static bool AreAnyFailedCheckouts(Dictionary<Guid, CheckoutStatus> guestStayCheckouts) =>
guestStayCheckouts.Values.Any(status => status is CheckoutStatus.Failed);

private static Guid[] CheckoutsWith(Dictionary<Guid, CheckoutStatus> 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
}
Original file line number Diff line number Diff line change
@@ -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<GroupCheckout>(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<object>().ToArray(), ct);
}

public async ValueTask RecordGuestCheckoutFailure(
RecordGuestCheckoutFailure command,
CancellationToken ct = default
)
{
var groupCheckout = await database.Get<GroupCheckout>(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<object>().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() { }
}
Original file line number Diff line number Diff line change
@@ -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<CheckOutGuest>[] 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>(T Message): SagaResult;

public record Event<T>(T Message): SagaResult;

public record None: SagaResult;

public static Command<T> Send<T>(T command) => new(command);

public static Event<T> Publish<T>(T @event) => new(@event);

public static readonly None Ignore = new();
}
Original file line number Diff line number Diff line change
@@ -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<GroupCheckoutEvent.GroupCheckoutInitiated>((@event, ct) =>
commandBus.Send(GroupCheckoutSaga.Handle(@event).Select(c => c.Message).ToArray(), ct)
)
.Subscribe<GuestCheckedOut>((@event, ct) =>
GroupCheckoutSaga.Handle(@event) is Command<RecordGuestCheckoutCompletion>(var command)
? commandBus.Send([command], ct)
: ValueTask.CompletedTask
)
.Subscribe<GuestCheckoutFailed>((@event, ct) =>
GroupCheckoutSaga.Handle(@event) is Command<RecordGuestCheckoutFailure>(var command)
? commandBus.Send([command], ct)
: ValueTask.CompletedTask
);

commandBus.Handle<RecordGuestCheckoutCompletion>(groupCheckoutFacade.RecordGuestCheckoutCompletion);
commandBus.Handle<RecordGuestCheckoutFailure>(groupCheckoutFacade.RecordGuestCheckoutFailure);
}
}
Original file line number Diff line number Diff line change
@@ -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<CheckOutGuest>(guestStayFacade.CheckOutGuest);
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<GuestStayAccount>(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<GuestStayAccount>(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<GuestStayAccount>(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
);
}

0 comments on commit 4f57da6

Please sign in to comment.