From 16ddcd6bc22f3ca243f9b779063af945356eb31d Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Tue, 21 Feb 2023 13:38:05 +0100 Subject: [PATCH] Added examples of process managers in HotelManagement example --- .../Aggregates/DocumentSessionExtensions.cs | 48 +++++ .../DocumentSessionExtensions.cs | 58 ++++++ Core/ProcessManagers/IProcessManager.cs | 30 +++ Core/ProcessManagers/ProcessManager.cs | 33 ++++ Core/Structures/Either.cs | 132 +++++++++++++ Core/Structures/OneOf.cs | 98 ++++++++++ .../HotelManagement.Tests.csproj | 2 +- .../GroupCheckouts/GroupCheckoutSagaTests.cs | 8 +- .../GroupCheckoutTests.CheckIn.cs | 4 +- ...koutTests.RecordGuestCheckoutCompletion.cs | 4 +- ...heckoutTests.RecordGuestCheckoutFailure.cs | 4 +- ...outTests.RecordGuestCheckoutsInitiation.cs | 4 +- .../GroupCheckouts/GroupCheckoutTests.cs | 4 +- .../GuestStayAccountTests.CheckIn.cs | 4 +- .../GuestStayAccountTests.CheckOut.cs | 4 +- .../GuestStayAccountTests.RecordCharge.cs | 4 +- .../GuestStayAccountTests.cs | 4 +- .../GroupCheckouts/GroupCheckout.cs | 182 ++++++++++++++++++ .../GroupCheckoutDomainService.cs | 73 +++++++ .../GroupCheckouts/GroupCheckoutSaga.cs | 65 +++++++ .../GuestStayAccounts/GuestStayAccount.cs | 117 +++++++++++ .../GuestStayAccountDomainService.cs | 68 +++++++ .../HotelManagement/HotelManagement.csproj | 4 +- .../GroupCheckoutDomainService.cs | 62 ++++++ .../GroupCheckoutProcessManager.cs | 171 ++++++++++++++++ .../GuestStayAccounts/GuestStayAccount.cs | 110 +++++++++++ .../GuestStayAccountDomainService.cs | 18 +- .../GroupCheckouts/GroupCheckout.cs | 2 +- .../GroupCheckoutDomainService.cs | 9 +- .../GroupCheckouts/GroupCheckoutSaga.cs | 10 +- .../GuestStayAccounts/GuestStayAccount.cs | 2 +- .../GuestStayAccountDomainService.cs | 68 +++++++ 32 files changed, 1363 insertions(+), 43 deletions(-) create mode 100644 Core.Marten/Aggregates/DocumentSessionExtensions.cs create mode 100644 Core.Marten/ProcessManagers/DocumentSessionExtensions.cs create mode 100644 Core/ProcessManagers/IProcessManager.cs create mode 100644 Core/ProcessManagers/ProcessManager.cs create mode 100644 Core/Structures/Either.cs create mode 100644 Core/Structures/OneOf.cs rename Sample/HotelManagement/HotelManagement.Tests/{Saga => Sagas}/GroupCheckouts/GroupCheckoutSagaTests.cs (94%) rename Sample/HotelManagement/HotelManagement.Tests/{Saga => Sagas}/GroupCheckouts/GroupCheckoutTests.CheckIn.cs (83%) rename Sample/HotelManagement/HotelManagement.Tests/{Saga => Sagas}/GroupCheckouts/GroupCheckoutTests.RecordGuestCheckoutCompletion.cs (98%) rename Sample/HotelManagement/HotelManagement.Tests/{Saga => Sagas}/GroupCheckouts/GroupCheckoutTests.RecordGuestCheckoutFailure.cs (98%) rename Sample/HotelManagement/HotelManagement.Tests/{Saga => Sagas}/GroupCheckouts/GroupCheckoutTests.RecordGuestCheckoutsInitiation.cs (90%) rename Sample/HotelManagement/HotelManagement.Tests/{Saga => Sagas}/GroupCheckouts/GroupCheckoutTests.cs (92%) rename Sample/HotelManagement/HotelManagement.Tests/{Saga => Sagas}/GuestStayAccounts/GuestStayAccountTests.CheckIn.cs (76%) rename Sample/HotelManagement/HotelManagement.Tests/{Saga => Sagas}/GuestStayAccounts/GuestStayAccountTests.CheckOut.cs (97%) rename Sample/HotelManagement/HotelManagement.Tests/{Saga => Sagas}/GuestStayAccounts/GuestStayAccountTests.RecordCharge.cs (97%) rename Sample/HotelManagement/HotelManagement.Tests/{Saga => Sagas}/GuestStayAccounts/GuestStayAccountTests.cs (89%) create mode 100644 Sample/HotelManagement/HotelManagement/Choreography/GroupCheckouts/GroupCheckout.cs create mode 100644 Sample/HotelManagement/HotelManagement/Choreography/GroupCheckouts/GroupCheckoutDomainService.cs create mode 100644 Sample/HotelManagement/HotelManagement/Choreography/GroupCheckouts/GroupCheckoutSaga.cs create mode 100644 Sample/HotelManagement/HotelManagement/Choreography/GuestStayAccounts/GuestStayAccount.cs create mode 100644 Sample/HotelManagement/HotelManagement/Choreography/GuestStayAccounts/GuestStayAccountDomainService.cs create mode 100644 Sample/HotelManagement/HotelManagement/ProcessManagers/GroupCheckouts/GroupCheckoutDomainService.cs create mode 100644 Sample/HotelManagement/HotelManagement/ProcessManagers/GroupCheckouts/GroupCheckoutProcessManager.cs create mode 100644 Sample/HotelManagement/HotelManagement/ProcessManagers/GuestStayAccounts/GuestStayAccount.cs rename Sample/HotelManagement/HotelManagement/{Saga => ProcessManagers}/GuestStayAccounts/GuestStayAccountDomainService.cs (80%) rename Sample/HotelManagement/HotelManagement/{Saga => Sagas}/GroupCheckouts/GroupCheckout.cs (99%) rename Sample/HotelManagement/HotelManagement/{Saga => Sagas}/GroupCheckouts/GroupCheckoutDomainService.cs (86%) rename Sample/HotelManagement/HotelManagement/{Saga => Sagas}/GroupCheckouts/GroupCheckoutSaga.cs (83%) rename Sample/HotelManagement/HotelManagement/{Saga => Sagas}/GuestStayAccounts/GuestStayAccount.cs (98%) create mode 100644 Sample/HotelManagement/HotelManagement/Sagas/GuestStayAccounts/GuestStayAccountDomainService.cs diff --git a/Core.Marten/Aggregates/DocumentSessionExtensions.cs b/Core.Marten/Aggregates/DocumentSessionExtensions.cs new file mode 100644 index 000000000..f06f0e691 --- /dev/null +++ b/Core.Marten/Aggregates/DocumentSessionExtensions.cs @@ -0,0 +1,48 @@ +using Core.Aggregates; +using Core.Exceptions; +using Marten; + +namespace Core.Marten.Aggregates; + +/// +/// This code assumes that Aggregate: +/// - is event-driven but not fully event-sourced +/// - streams have string identifiers +/// - aggregate is versioned, so optimistic concurrency is applied +/// +public static class DocumentSessionExtensions +{ + public static Task Add( + this IDocumentSession documentSession, + string id, + T aggregate, + CancellationToken ct + ) where T : IAggregate + { + documentSession.Insert(aggregate); + documentSession.Events.Append($"events-{id}", aggregate.DequeueUncommittedEvents()); + + return documentSession.SaveChangesAsync(token: ct); + } + + public static async Task GetAndUpdate( + this IDocumentSession documentSession, + string id, + Action handle, + CancellationToken ct + ) where T : IAggregate + { + var aggregate = await documentSession.LoadAsync(id, ct).ConfigureAwait(false); + + if (aggregate is null) + throw AggregateNotFoundException.For(id); + + handle(aggregate); + + documentSession.Update(aggregate); + + documentSession.Events.Append($"events-{id}", aggregate.DequeueUncommittedEvents()); + + await documentSession.SaveChangesAsync(token: ct).ConfigureAwait(false); + } +} diff --git a/Core.Marten/ProcessManagers/DocumentSessionExtensions.cs b/Core.Marten/ProcessManagers/DocumentSessionExtensions.cs new file mode 100644 index 000000000..16ab947fa --- /dev/null +++ b/Core.Marten/ProcessManagers/DocumentSessionExtensions.cs @@ -0,0 +1,58 @@ +using Core.Exceptions; +using Core.ProcessManagers; +using Marten; + +namespace Core.Marten.ProcessManagers; + +/// +/// This code assumes that Process Manager: +/// - is event-driven but not fully event-sourced +/// - streams have string identifiers +/// - process manager is versioned, so optimistic concurrency is applied +/// +public static class DocumentSessionExtensions +{ + public static Task Add( + this IDocumentSession documentSession, + string id, + T processManager, + CancellationToken ct + ) where T : IProcessManager + { + documentSession.Insert(processManager); + EnqueueMessages(documentSession, id, processManager); + + return documentSession.SaveChangesAsync(token: ct); + } + + public static async Task GetAndUpdate( + this IDocumentSession documentSession, + string id, + Action handle, + CancellationToken ct + ) where T : IProcessManager + { + var processManager = await documentSession.LoadAsync(id, ct).ConfigureAwait(false); + + if (processManager is null) + throw AggregateNotFoundException.For(id); + + handle(processManager); + + documentSession.Update(processManager); + + EnqueueMessages(documentSession, id, processManager); + await documentSession.SaveChangesAsync(token: ct).ConfigureAwait(false); + } + + private static void EnqueueMessages(IDocumentSession documentSession, string id, T processManager) where T : IProcessManager + { + foreach (var message in processManager.DequeuePendingMessages()) + { + message.Switch( + @event => documentSession.Events.Append($"events-{id}", @event), + command => documentSession.Events.Append($"commands-{id}", command) + ); + } + } +} diff --git a/Core/ProcessManagers/IProcessManager.cs b/Core/ProcessManagers/IProcessManager.cs new file mode 100644 index 000000000..6bac30c6c --- /dev/null +++ b/Core/ProcessManagers/IProcessManager.cs @@ -0,0 +1,30 @@ +using Core.Projections; +using Core.Structures; + +namespace Core.ProcessManagers; + +public interface IProcessManager: IProcessManager +{ +} + +public interface IProcessManager: IProjection +{ + T Id { get; } + int Version { get; } + + EventOrCommand[] DequeuePendingMessages(); +} + +public class EventOrCommand: Either +{ + public static EventOrCommand Event(object @event) => + new(Maybe.Of(@event), Maybe.Empty); + + + public static EventOrCommand Command(object @event) => + new(Maybe.Empty, Maybe.Of(@event)); + + private EventOrCommand(Maybe left, Maybe right): base(left, right) + { + } +} diff --git a/Core/ProcessManagers/ProcessManager.cs b/Core/ProcessManagers/ProcessManager.cs new file mode 100644 index 000000000..091fcc378 --- /dev/null +++ b/Core/ProcessManagers/ProcessManager.cs @@ -0,0 +1,33 @@ +namespace Core.ProcessManagers; + +public abstract class ProcessManager: ProcessManager, IProcessManager +{ +} + +public abstract class ProcessManager: IProcessManager where T : notnull +{ + public T Id { get; protected set; } = default!; + + public int Version { get; protected set; } + + [NonSerialized] private readonly Queue scheduledCommands = new(); + + public EventOrCommand[] DequeuePendingMessages() + { + var dequeuedEvents = scheduledCommands.ToArray(); + + scheduledCommands.Clear(); + + return dequeuedEvents; + } + + protected void EnqueueEvent(object @event) => + scheduledCommands.Enqueue(EventOrCommand.Event(@event)); + + protected void ScheduleCommand(object @event) => + scheduledCommands.Enqueue(EventOrCommand.Command(@event)); + + public virtual void When(object @event) + { + } +} diff --git a/Core/Structures/Either.cs b/Core/Structures/Either.cs new file mode 100644 index 000000000..b5c078da5 --- /dev/null +++ b/Core/Structures/Either.cs @@ -0,0 +1,132 @@ +namespace Core.Structures; + +public class Either +{ + public Maybe Left { get; } + public Maybe Right { get; } + + public Either(TLeft value) + { + Left = Maybe.Of(value); + Right = Maybe.Empty; + } + + public Either(TRight value) + { + Left = Maybe.Empty; + Right = Maybe.Of(value); + } + + public Either(Maybe left, Maybe right) + { + if (!left.IsPresent && !right.IsPresent) + throw new ArgumentOutOfRangeException(nameof(right)); + + Left = left; + Right = right; + } + + public TMapped Map( + Func mapLeft, + Func mapRight + ) + { + if (Left.IsPresent) + return mapLeft(Left.GetOrThrow()); + + if (Right.IsPresent) + return mapRight(Right.GetOrThrow()); + + throw new Exception("That should never happen!"); + } + + public void Switch( + Action onLeft, + Action onRight + ) + { + if (Left.IsPresent) + { + onLeft(Left.GetOrThrow()); + return; + } + + if (Right.IsPresent) + { + onRight(Right.GetOrThrow()); + return; + } + + throw new Exception("That should never happen!"); + } +} + +public static class EitherExtensions +{ + public static (TLeft? Left, TRight? Right) AssertAnyDefined( + this (TLeft? Left, TRight? Right) value + ) + { + if (value.Left == null && value.Right == null) + throw new ArgumentOutOfRangeException(nameof(value), "One of values needs to be set"); + + return value; + } + + public static TMapped Map( + this (TLeft? Left, TRight? Right) value, + Func mapLeft, + Func mapRight + ) + where TLeft: struct + where TRight: struct + { + var (left, right) = value.AssertAnyDefined(); + + if (left.HasValue) + return mapLeft(left.Value); + + if (right.HasValue) + return mapRight(right.Value); + + throw new Exception("That should never happen!"); + } + + public static TMapped Map( + this (TLeft? Left, TRight? Right) value, + Func mapT1, + Func mapT2 + ) + { + value.AssertAnyDefined(); + + var either = value.Left != null + ? new Either(value.Left!) + : new Either(value.Right!); + + return either.Map(mapT1, mapT2); + } + + public static void Switch( + this (TLeft? Left, TRight? Right) value, + Action onT1, + Action onT2 + ) + { + value.AssertAnyDefined(); + + var either = value.Left != null + ? new Either(value.Left!) + : new Either(value.Right!); + + either.Switch(onT1, onT2); + } + + public static (TLeft?, TRight?) Either( + TLeft? left = default + ) => (left, default); + + public static (TLeft?, TRight?) Either( + TRight? right = default + ) => (default, right); +} diff --git a/Core/Structures/OneOf.cs b/Core/Structures/OneOf.cs new file mode 100644 index 000000000..83f0ac237 --- /dev/null +++ b/Core/Structures/OneOf.cs @@ -0,0 +1,98 @@ +namespace Core.Structures; + +public class OneOf +{ + public Maybe First { get; } + public Maybe Second { get; } + public Maybe Third { get; } + + public OneOf(T1 value) + { + First = Maybe.Of(value); + Second = Maybe.Empty; + Third = Maybe.Empty; + } + + public OneOf(T2 value) + { + First = Maybe.Empty; + Second = Maybe.Of(value); + Third = Maybe.Empty; + } + + public OneOf(T3 value) + { + First = Maybe.Empty; + Second = Maybe.Empty; + Third = Maybe.Of(value); + } + + public OneOf((T1? First, T2? Second, T3? Third) value) + { + First = value.First != null ? Maybe.Of(value.First) : Maybe.Empty; + Second = value.Second != null ? Maybe.Of(value.Second) : Maybe.Empty; + Third = value.Third != null ? Maybe.Of(value.Third) : Maybe.Empty; + } + + public TMapped Map( + Func mapT1, + Func mapT2, + Func mapT3 + ) + { + if (First.IsPresent) + return mapT1(First.GetOrThrow()); + + if (Second.IsPresent) + return mapT2(Second.GetOrThrow()); + + if (Third.IsPresent) + return mapT3(Third.GetOrThrow()); + + throw new Exception("That should never happen!"); + } + + public void Switch( + Action onT1, + Action onT2, + Action onT3 + ) + { + if (First.IsPresent) + { + onT1(First.GetOrThrow()); + return; + } + + if (Second.IsPresent) + { + onT2(Second.GetOrThrow()); + return; + } + + if (Third.IsPresent) + { + onT3(Third.GetOrThrow()); + return; + } + + throw new Exception("That should never happen!"); + } +} + +public static class OneOfExtensions +{ + public static void Map( + this (T1? First, T2? Second, T3? Third) value, + Func mapT1, + Func mapT2, + Func mapT3 + ) => new OneOf(value).Map(mapT1, mapT2, mapT3); + + public static void Switch( + this (T1? First, T2? Second, T3? Third) value, + Action onT1, + Action onT2, + Action onT3 + ) => new OneOf(value).Switch(onT1, onT2, onT3); +} diff --git a/Sample/HotelManagement/HotelManagement.Tests/HotelManagement.Tests.csproj b/Sample/HotelManagement/HotelManagement.Tests/HotelManagement.Tests.csproj index 7fb1b5d38..4b41566b0 100644 --- a/Sample/HotelManagement/HotelManagement.Tests/HotelManagement.Tests.csproj +++ b/Sample/HotelManagement/HotelManagement.Tests/HotelManagement.Tests.csproj @@ -22,6 +22,6 @@ - + diff --git a/Sample/HotelManagement/HotelManagement.Tests/Saga/GroupCheckouts/GroupCheckoutSagaTests.cs b/Sample/HotelManagement/HotelManagement.Tests/Sagas/GroupCheckouts/GroupCheckoutSagaTests.cs similarity index 94% rename from Sample/HotelManagement/HotelManagement.Tests/Saga/GroupCheckouts/GroupCheckoutSagaTests.cs rename to Sample/HotelManagement/HotelManagement.Tests/Sagas/GroupCheckouts/GroupCheckoutSagaTests.cs index 4dc486c17..0618498e7 100644 --- a/Sample/HotelManagement/HotelManagement.Tests/Saga/GroupCheckouts/GroupCheckoutSagaTests.cs +++ b/Sample/HotelManagement/HotelManagement.Tests/Sagas/GroupCheckouts/GroupCheckoutSagaTests.cs @@ -1,11 +1,11 @@ using Core.Commands; using FluentAssertions; -using HotelManagement.Saga.GroupCheckouts; -using HotelManagement.Saga.GuestStayAccounts; +using HotelManagement.Sagas.GroupCheckouts; +using HotelManagement.Sagas.GuestStayAccounts; using Xunit; -using GuestCheckoutFailed = HotelManagement.Saga.GuestStayAccounts.GuestCheckoutFailed; +using GuestCheckoutFailed = HotelManagement.Sagas.GuestStayAccounts.GuestCheckoutFailed; -namespace HotelManagement.Tests.Saga.GroupCheckouts; +namespace HotelManagement.Tests.Sagas.GroupCheckouts; using static GuestCheckoutFailed; diff --git a/Sample/HotelManagement/HotelManagement.Tests/Saga/GroupCheckouts/GroupCheckoutTests.CheckIn.cs b/Sample/HotelManagement/HotelManagement.Tests/Sagas/GroupCheckouts/GroupCheckoutTests.CheckIn.cs similarity index 83% rename from Sample/HotelManagement/HotelManagement.Tests/Saga/GroupCheckouts/GroupCheckoutTests.CheckIn.cs rename to Sample/HotelManagement/HotelManagement.Tests/Sagas/GroupCheckouts/GroupCheckoutTests.CheckIn.cs index ec7cf55c1..047877354 100644 --- a/Sample/HotelManagement/HotelManagement.Tests/Saga/GroupCheckouts/GroupCheckoutTests.CheckIn.cs +++ b/Sample/HotelManagement/HotelManagement.Tests/Sagas/GroupCheckouts/GroupCheckoutTests.CheckIn.cs @@ -1,8 +1,8 @@ -using HotelManagement.Saga.GroupCheckouts; +using HotelManagement.Sagas.GroupCheckouts; using Ogooreck.BusinessLogic; using Xunit; -namespace HotelManagement.Tests.Saga.GroupCheckouts; +namespace HotelManagement.Tests.Sagas.GroupCheckouts; public partial class GroupCheckoutTests { diff --git a/Sample/HotelManagement/HotelManagement.Tests/Saga/GroupCheckouts/GroupCheckoutTests.RecordGuestCheckoutCompletion.cs b/Sample/HotelManagement/HotelManagement.Tests/Sagas/GroupCheckouts/GroupCheckoutTests.RecordGuestCheckoutCompletion.cs similarity index 98% rename from Sample/HotelManagement/HotelManagement.Tests/Saga/GroupCheckouts/GroupCheckoutTests.RecordGuestCheckoutCompletion.cs rename to Sample/HotelManagement/HotelManagement.Tests/Sagas/GroupCheckouts/GroupCheckoutTests.RecordGuestCheckoutCompletion.cs index d11b6ac8c..e2f3597bf 100644 --- a/Sample/HotelManagement/HotelManagement.Tests/Saga/GroupCheckouts/GroupCheckoutTests.RecordGuestCheckoutCompletion.cs +++ b/Sample/HotelManagement/HotelManagement.Tests/Sagas/GroupCheckouts/GroupCheckoutTests.RecordGuestCheckoutCompletion.cs @@ -1,8 +1,8 @@ -using HotelManagement.Saga.GroupCheckouts; +using HotelManagement.Sagas.GroupCheckouts; using Ogooreck.BusinessLogic; using Xunit; -namespace HotelManagement.Tests.Saga.GroupCheckouts; +namespace HotelManagement.Tests.Sagas.GroupCheckouts; public partial class GroupCheckoutTests { diff --git a/Sample/HotelManagement/HotelManagement.Tests/Saga/GroupCheckouts/GroupCheckoutTests.RecordGuestCheckoutFailure.cs b/Sample/HotelManagement/HotelManagement.Tests/Sagas/GroupCheckouts/GroupCheckoutTests.RecordGuestCheckoutFailure.cs similarity index 98% rename from Sample/HotelManagement/HotelManagement.Tests/Saga/GroupCheckouts/GroupCheckoutTests.RecordGuestCheckoutFailure.cs rename to Sample/HotelManagement/HotelManagement.Tests/Sagas/GroupCheckouts/GroupCheckoutTests.RecordGuestCheckoutFailure.cs index 7df78d896..ec162e7ab 100644 --- a/Sample/HotelManagement/HotelManagement.Tests/Saga/GroupCheckouts/GroupCheckoutTests.RecordGuestCheckoutFailure.cs +++ b/Sample/HotelManagement/HotelManagement.Tests/Sagas/GroupCheckouts/GroupCheckoutTests.RecordGuestCheckoutFailure.cs @@ -1,8 +1,8 @@ -using HotelManagement.Saga.GroupCheckouts; +using HotelManagement.Sagas.GroupCheckouts; using Ogooreck.BusinessLogic; using Xunit; -namespace HotelManagement.Tests.Saga.GroupCheckouts; +namespace HotelManagement.Tests.Sagas.GroupCheckouts; public partial class GroupCheckoutTests { diff --git a/Sample/HotelManagement/HotelManagement.Tests/Saga/GroupCheckouts/GroupCheckoutTests.RecordGuestCheckoutsInitiation.cs b/Sample/HotelManagement/HotelManagement.Tests/Sagas/GroupCheckouts/GroupCheckoutTests.RecordGuestCheckoutsInitiation.cs similarity index 90% rename from Sample/HotelManagement/HotelManagement.Tests/Saga/GroupCheckouts/GroupCheckoutTests.RecordGuestCheckoutsInitiation.cs rename to Sample/HotelManagement/HotelManagement.Tests/Sagas/GroupCheckouts/GroupCheckoutTests.RecordGuestCheckoutsInitiation.cs index 4707e7c01..f279bda09 100644 --- a/Sample/HotelManagement/HotelManagement.Tests/Saga/GroupCheckouts/GroupCheckoutTests.RecordGuestCheckoutsInitiation.cs +++ b/Sample/HotelManagement/HotelManagement.Tests/Sagas/GroupCheckouts/GroupCheckoutTests.RecordGuestCheckoutsInitiation.cs @@ -1,8 +1,8 @@ -using HotelManagement.Saga.GroupCheckouts; +using HotelManagement.Sagas.GroupCheckouts; using Ogooreck.BusinessLogic; using Xunit; -namespace HotelManagement.Tests.Saga.GroupCheckouts; +namespace HotelManagement.Tests.Sagas.GroupCheckouts; public partial class GroupCheckoutTests { diff --git a/Sample/HotelManagement/HotelManagement.Tests/Saga/GroupCheckouts/GroupCheckoutTests.cs b/Sample/HotelManagement/HotelManagement.Tests/Sagas/GroupCheckouts/GroupCheckoutTests.cs similarity index 92% rename from Sample/HotelManagement/HotelManagement.Tests/Saga/GroupCheckouts/GroupCheckoutTests.cs rename to Sample/HotelManagement/HotelManagement.Tests/Sagas/GroupCheckouts/GroupCheckoutTests.cs index 162553187..b35d98f3d 100644 --- a/Sample/HotelManagement/HotelManagement.Tests/Saga/GroupCheckouts/GroupCheckoutTests.cs +++ b/Sample/HotelManagement/HotelManagement.Tests/Sagas/GroupCheckouts/GroupCheckoutTests.cs @@ -1,8 +1,8 @@ using Bogus; -using HotelManagement.Saga.GroupCheckouts; +using HotelManagement.Sagas.GroupCheckouts; using Ogooreck.BusinessLogic; -namespace HotelManagement.Tests.Saga.GroupCheckouts; +namespace HotelManagement.Tests.Sagas.GroupCheckouts; public partial class GroupCheckoutTests { diff --git a/Sample/HotelManagement/HotelManagement.Tests/Saga/GuestStayAccounts/GuestStayAccountTests.CheckIn.cs b/Sample/HotelManagement/HotelManagement.Tests/Sagas/GuestStayAccounts/GuestStayAccountTests.CheckIn.cs similarity index 76% rename from Sample/HotelManagement/HotelManagement.Tests/Saga/GuestStayAccounts/GuestStayAccountTests.CheckIn.cs rename to Sample/HotelManagement/HotelManagement.Tests/Sagas/GuestStayAccounts/GuestStayAccountTests.CheckIn.cs index 58d75c2a1..bb65e28f9 100644 --- a/Sample/HotelManagement/HotelManagement.Tests/Saga/GuestStayAccounts/GuestStayAccountTests.CheckIn.cs +++ b/Sample/HotelManagement/HotelManagement.Tests/Sagas/GuestStayAccounts/GuestStayAccountTests.CheckIn.cs @@ -1,8 +1,8 @@ -using HotelManagement.Saga.GuestStayAccounts; +using HotelManagement.Sagas.GuestStayAccounts; using Ogooreck.BusinessLogic; using Xunit; -namespace HotelManagement.Tests.Saga.GuestStayAccounts; +namespace HotelManagement.Tests.Sagas.GuestStayAccounts; public partial class GuestStayAccountTests { diff --git a/Sample/HotelManagement/HotelManagement.Tests/Saga/GuestStayAccounts/GuestStayAccountTests.CheckOut.cs b/Sample/HotelManagement/HotelManagement.Tests/Sagas/GuestStayAccounts/GuestStayAccountTests.CheckOut.cs similarity index 97% rename from Sample/HotelManagement/HotelManagement.Tests/Saga/GuestStayAccounts/GuestStayAccountTests.CheckOut.cs rename to Sample/HotelManagement/HotelManagement.Tests/Sagas/GuestStayAccounts/GuestStayAccountTests.CheckOut.cs index 94bfe716b..9f38c6aca 100644 --- a/Sample/HotelManagement/HotelManagement.Tests/Saga/GuestStayAccounts/GuestStayAccountTests.CheckOut.cs +++ b/Sample/HotelManagement/HotelManagement.Tests/Sagas/GuestStayAccounts/GuestStayAccountTests.CheckOut.cs @@ -1,8 +1,8 @@ -using HotelManagement.Saga.GuestStayAccounts; +using HotelManagement.Sagas.GuestStayAccounts; using Ogooreck.BusinessLogic; using Xunit; -namespace HotelManagement.Tests.Saga.GuestStayAccounts; +namespace HotelManagement.Tests.Sagas.GuestStayAccounts; using static GuestCheckoutFailed; diff --git a/Sample/HotelManagement/HotelManagement.Tests/Saga/GuestStayAccounts/GuestStayAccountTests.RecordCharge.cs b/Sample/HotelManagement/HotelManagement.Tests/Sagas/GuestStayAccounts/GuestStayAccountTests.RecordCharge.cs similarity index 97% rename from Sample/HotelManagement/HotelManagement.Tests/Saga/GuestStayAccounts/GuestStayAccountTests.RecordCharge.cs rename to Sample/HotelManagement/HotelManagement.Tests/Sagas/GuestStayAccounts/GuestStayAccountTests.RecordCharge.cs index fb66fcbe2..0b9d25b20 100644 --- a/Sample/HotelManagement/HotelManagement.Tests/Saga/GuestStayAccounts/GuestStayAccountTests.RecordCharge.cs +++ b/Sample/HotelManagement/HotelManagement.Tests/Sagas/GuestStayAccounts/GuestStayAccountTests.RecordCharge.cs @@ -1,8 +1,8 @@ -using HotelManagement.Saga.GuestStayAccounts; +using HotelManagement.Sagas.GuestStayAccounts; using Ogooreck.BusinessLogic; using Xunit; -namespace HotelManagement.Tests.Saga.GuestStayAccounts; +namespace HotelManagement.Tests.Sagas.GuestStayAccounts; public partial class GuestStayAccountTests { diff --git a/Sample/HotelManagement/HotelManagement.Tests/Saga/GuestStayAccounts/GuestStayAccountTests.cs b/Sample/HotelManagement/HotelManagement.Tests/Sagas/GuestStayAccounts/GuestStayAccountTests.cs similarity index 89% rename from Sample/HotelManagement/HotelManagement.Tests/Saga/GuestStayAccounts/GuestStayAccountTests.cs rename to Sample/HotelManagement/HotelManagement.Tests/Sagas/GuestStayAccounts/GuestStayAccountTests.cs index 98a5c234c..6ec0b3389 100644 --- a/Sample/HotelManagement/HotelManagement.Tests/Saga/GuestStayAccounts/GuestStayAccountTests.cs +++ b/Sample/HotelManagement/HotelManagement.Tests/Sagas/GuestStayAccounts/GuestStayAccountTests.cs @@ -1,8 +1,8 @@ using Bogus; -using HotelManagement.Saga.GuestStayAccounts; +using HotelManagement.Sagas.GuestStayAccounts; using Ogooreck.BusinessLogic; -namespace HotelManagement.Tests.Saga.GuestStayAccounts; +namespace HotelManagement.Tests.Sagas.GuestStayAccounts; public partial class GuestStayAccountTests { diff --git a/Sample/HotelManagement/HotelManagement/Choreography/GroupCheckouts/GroupCheckout.cs b/Sample/HotelManagement/HotelManagement/Choreography/GroupCheckouts/GroupCheckout.cs new file mode 100644 index 000000000..e380b7ca6 --- /dev/null +++ b/Sample/HotelManagement/HotelManagement/Choreography/GroupCheckouts/GroupCheckout.cs @@ -0,0 +1,182 @@ +using Core.Extensions; +using Core.Structures; + +namespace HotelManagement.Choreography.GroupCheckouts; + +public record GroupCheckoutInitiated( + Guid GroupCheckoutId, + Guid ClerkId, + Guid[] GuestStayIds, + DateTimeOffset InitiatedAt +); + +public record GuestCheckoutsInitiated( + Guid GroupCheckoutId, + Guid[] InitiatedGuestStayIds, + DateTimeOffset InitiatedAt +); + +public record GuestCheckoutCompleted( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset CompletedAt +); + +public record GuestCheckoutFailed( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset CompletedAt +); + +public record GroupCheckoutCompleted( + Guid GroupCheckoutId, + Guid[] CompletedCheckouts, + DateTimeOffset CompletedAt +); + +public record GroupCheckoutFailed( + Guid GroupCheckoutId, + Guid[] CompletedCheckouts, + Guid[] FailedCheckouts, + DateTimeOffset FailedAt +); + +public record GroupCheckout( + Guid Id, + Dictionary GuestStayCheckouts, + CheckoutStatus Status = CheckoutStatus.Initiated +) +{ + public static GroupCheckoutInitiated Initiate( + Guid groupCheckoutId, + Guid clerkId, + Guid[] guestStayIds, + DateTimeOffset initiatedAt + ) => + new GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStayIds, initiatedAt); + + public Maybe RecordGuestCheckoutsInitiation( + Guid[] initiatedGuestStayIds, + DateTimeOffset now + ) => + Maybe.If( + Status == CheckoutStatus.Initiated, + () => new GuestCheckoutsInitiated(Id, initiatedGuestStayIds, now) + ); + + public Maybe RecordGuestCheckoutCompletion( + Guid guestStayId, + DateTimeOffset now + ) => + Maybe.If( + Status == CheckoutStatus.Initiated && GuestStayCheckouts[guestStayId] != CheckoutStatus.Completed, + () => + { + var guestCheckoutCompleted = new GuestCheckoutCompleted(Id, guestStayId, now); + + var guestStayCheckouts = GuestStayCheckouts.With(guestStayId, CheckoutStatus.Completed); + + return AreAnyOngoingCheckouts(guestStayCheckouts) + ? new object[] { guestCheckoutCompleted } + : new[] { guestCheckoutCompleted, Finalize(guestStayCheckouts, now) }; + }); + + public Maybe RecordGuestCheckoutFailure( + Guid guestStayId, + DateTimeOffset now + ) => + Maybe.If( + Status == CheckoutStatus.Initiated && GuestStayCheckouts[guestStayId] != CheckoutStatus.Failed, + () => + { + var guestCheckoutFailed = new GuestCheckoutFailed(Id, guestStayId, now); + + var guestStayCheckouts = GuestStayCheckouts.With(guestStayId, CheckoutStatus.Failed); + + return AreAnyOngoingCheckouts(guestStayCheckouts) + ? new object[] { guestCheckoutFailed } + : new[] { guestCheckoutFailed, Finalize(guestStayCheckouts, now) }; + }); + + private object 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 or CheckoutStatus.Pending); + + 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 static GroupCheckout Create(GroupCheckoutInitiated @event) => + new GroupCheckout( + @event.GroupCheckoutId, + @event.GuestStayIds.ToDictionary(id => id, _ => CheckoutStatus.Pending) + ); + + public GroupCheckout Apply(GuestCheckoutsInitiated @event) + { + var initiated = @event.InitiatedGuestStayIds.ToDictionary( + id => id, + _ => CheckoutStatus.Initiated + ); + + return this with + { + GuestStayCheckouts = GuestStayCheckouts.ToDictionary( + ks => ks.Key, + vs => initiated.GetValueOrDefault(vs.Key, vs.Value) + ) + }; + } + + public GroupCheckout Apply(GuestCheckoutCompleted @event) => + this with + { + GuestStayCheckouts = GuestStayCheckouts.ToDictionary( + ks => ks.Key, + vs => vs.Key == @event.GuestStayId ? CheckoutStatus.Completed : vs.Value + ) + }; + + public GroupCheckout Apply(GuestCheckoutFailed @event) => + this with + { + GuestStayCheckouts = GuestStayCheckouts.ToDictionary( + ks => ks.Key, + vs => vs.Key == @event.GuestStayId ? CheckoutStatus.Failed : vs.Value + ) + }; + + public GroupCheckout Apply(GroupCheckoutCompleted @event) => + this with { Status = CheckoutStatus.Completed }; + + public GroupCheckout Apply(GroupCheckoutFailed @event) => + this with { Status = CheckoutStatus.Failed }; +} + +public enum CheckoutStatus +{ + Pending, + Initiated, + Completed, + Failed +} diff --git a/Sample/HotelManagement/HotelManagement/Choreography/GroupCheckouts/GroupCheckoutDomainService.cs b/Sample/HotelManagement/HotelManagement/Choreography/GroupCheckouts/GroupCheckoutDomainService.cs new file mode 100644 index 000000000..320e481f8 --- /dev/null +++ b/Sample/HotelManagement/HotelManagement/Choreography/GroupCheckouts/GroupCheckoutDomainService.cs @@ -0,0 +1,73 @@ +using Core.Commands; +using Core.Marten.Extensions; +using Marten; + +namespace HotelManagement.Choreography.GroupCheckouts; + +public record InitiateGroupCheckout( + Guid GroupCheckoutId, + Guid ClerkId, + Guid[] GuestStayIds +); + +public record RecordGuestCheckoutsInitiation( + Guid GroupCheckoutId, + Guid[] InitiatedGuestStayIds +); + +public record RecordGuestCheckoutCompletion( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset CompletedAt +); + +public record RecordGuestCheckoutFailure( + Guid GroupCheckoutId, + Guid GuestStayId, + DateTimeOffset FailedAt +); + +public class GuestStayDomainService: + ICommandHandler, + ICommandHandler, + ICommandHandler, + ICommandHandler +{ + private readonly IDocumentSession documentSession; + + public GuestStayDomainService(IDocumentSession documentSession) => + this.documentSession = documentSession; + + public Task Handle(InitiateGroupCheckout command, CancellationToken ct) => + documentSession.Add( + command.GroupCheckoutId, + GroupCheckout.Initiate( + command.GroupCheckoutId, + command.ClerkId, + command.GuestStayIds, + DateTimeOffset.UtcNow + ), + ct + ); + + public Task Handle(RecordGuestCheckoutsInitiation command, CancellationToken ct) => + documentSession.GetAndUpdate( + command.GroupCheckoutId, + (GroupCheckout state) => state.RecordGuestCheckoutsInitiation(command.InitiatedGuestStayIds, DateTimeOffset.UtcNow), + ct + ); + + public Task Handle(RecordGuestCheckoutCompletion command, CancellationToken ct) => + documentSession.GetAndUpdate( + command.GuestStayId, + (GroupCheckout state) => state.RecordGuestCheckoutCompletion(command.GuestStayId, command.CompletedAt), + ct + ); + + public Task Handle(RecordGuestCheckoutFailure command, CancellationToken ct) => + documentSession.GetAndUpdate( + command.GuestStayId, + (GroupCheckout state) => state.RecordGuestCheckoutFailure(command.GuestStayId, command.FailedAt), + ct + ); +} diff --git a/Sample/HotelManagement/HotelManagement/Choreography/GroupCheckouts/GroupCheckoutSaga.cs b/Sample/HotelManagement/HotelManagement/Choreography/GroupCheckouts/GroupCheckoutSaga.cs new file mode 100644 index 000000000..4aa0f0cab --- /dev/null +++ b/Sample/HotelManagement/HotelManagement/Choreography/GroupCheckouts/GroupCheckoutSaga.cs @@ -0,0 +1,65 @@ +using Core.Commands; +using Core.Events; +using HotelManagement.Choreography.GuestStayAccounts; + +namespace HotelManagement.Choreography.GroupCheckouts; + +public class GroupCheckoutSaga: + IEventHandler, + IEventHandler, + IEventHandler +{ + private readonly IAsyncCommandBus commandBus; + + public GroupCheckoutSaga(IAsyncCommandBus commandBus) => + this.commandBus = commandBus; + + public async Task Handle(GroupCheckoutInitiated @event, CancellationToken ct) + { + foreach (var guestAccountId in @event.GuestStayIds) + { + await commandBus.Schedule( + new CheckOutGuest(guestAccountId, @event.GroupCheckoutId), + ct + ); + } + + await commandBus.Schedule( + new RecordGuestCheckoutsInitiation( + @event.GroupCheckoutId, + @event.GuestStayIds + ), + ct + ); + } + + public Task Handle(Sagas.GuestStayAccounts.GuestCheckedOut @event, CancellationToken ct) + { + if (!@event.GroupCheckOutId.HasValue) + return Task.CompletedTask; + + return commandBus.Schedule( + new RecordGuestCheckoutCompletion( + @event.GroupCheckOutId.Value, + @event.GuestStayId, + @event.CheckedOutAt + ), + ct + ); + } + + public Task Handle(Sagas.GuestStayAccounts.GuestCheckoutFailed @event, CancellationToken ct) + { + if (!@event.GroupCheckOutId.HasValue) + return Task.CompletedTask; + + return commandBus.Schedule( + new RecordGuestCheckoutFailure( + @event.GroupCheckOutId.Value, + @event.GuestStayId, + @event.FailedAt + ), + ct + ); + } +} diff --git a/Sample/HotelManagement/HotelManagement/Choreography/GuestStayAccounts/GuestStayAccount.cs b/Sample/HotelManagement/HotelManagement/Choreography/GuestStayAccounts/GuestStayAccount.cs new file mode 100644 index 000000000..56c663c99 --- /dev/null +++ b/Sample/HotelManagement/HotelManagement/Choreography/GuestStayAccounts/GuestStayAccount.cs @@ -0,0 +1,117 @@ +using Core.Structures; +using static Core.Structures.Result; + +namespace HotelManagement.Choreography.GuestStayAccounts; + +public record GuestCheckedIn( + Guid GuestStayId, + DateTimeOffset CheckedInAt +); + +public record ChargeRecorded( + Guid GuestStayId, + decimal Amount, + DateTimeOffset RecordedAt +); + +public record PaymentRecorded( + Guid GuestStayId, + decimal Amount, + DateTimeOffset RecordedAt +); + +public record GuestCheckedOut( + Guid GuestStayId, + DateTimeOffset CheckedOutAt, + Guid? GroupCheckOutId = null +); + +public record GuestCheckoutFailed( + Guid GuestStayId, + GuestCheckoutFailed.FailureReason Reason, + DateTimeOffset FailedAt, + Guid? GroupCheckOutId = null +) +{ + public enum FailureReason + { + NotOpened, + BalanceNotSettled + } +} + +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 GuestCheckedIn(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 Result CheckOut(DateTimeOffset now, Guid? groupCheckoutId = null) + { + if (Status != GuestStayAccountStatus.Opened) + return Failure( + new GuestCheckoutFailed( + Id, + GuestCheckoutFailed.FailureReason.NotOpened, + now, + groupCheckoutId + ) + ); + + return IsSettled + ? Success( + new GuestCheckedOut( + Id, + now, + groupCheckoutId + ) + ) + : Failure( + new GuestCheckoutFailed( + Id, + GuestCheckoutFailed.FailureReason.BalanceNotSettled, + now, + groupCheckoutId + ) + ); + } + + public static GuestStayAccount Create(GuestCheckedIn @event) => + new GuestStayAccount(@event.GuestStayId); + + public GuestStayAccount Apply(ChargeRecorded @event) => + this with { Balance = Balance - @event.Amount }; + + public GuestStayAccount Apply(PaymentRecorded @event) => + this with { Balance = Balance + @event.Amount }; + + public GuestStayAccount Apply(GuestCheckedOut @event) => + this with { Status = GuestStayAccountStatus.CheckedOut }; +} + +public enum GuestStayAccountStatus +{ + Opened = 1, + CheckedOut = 2 +} diff --git a/Sample/HotelManagement/HotelManagement/Choreography/GuestStayAccounts/GuestStayAccountDomainService.cs b/Sample/HotelManagement/HotelManagement/Choreography/GuestStayAccounts/GuestStayAccountDomainService.cs new file mode 100644 index 000000000..6e0ddaff9 --- /dev/null +++ b/Sample/HotelManagement/HotelManagement/Choreography/GuestStayAccounts/GuestStayAccountDomainService.cs @@ -0,0 +1,68 @@ +using Core.Commands; +using Core.Marten.Extensions; +using Marten; + +namespace HotelManagement.Choreography.GuestStayAccounts; + +public record CheckInGuest( + Guid GuestStayId +); + +public record RecordCharge( + Guid GuestStayId, + decimal Amount, + int ExpectedVersion +); + +public record RecordPayment( + Guid GuestStayId, + decimal Amount, + int ExpectedVersion +); + +public record CheckOutGuest( + Guid GuestStayId, + Guid? GroupCheckOutId = null +); + +public class GuestStayDomainService: + ICommandHandler, + ICommandHandler, + ICommandHandler, + ICommandHandler +{ + private readonly IDocumentSession documentSession; + + public GuestStayDomainService(IDocumentSession documentSession) => + this.documentSession = documentSession; + + public Task Handle(CheckInGuest command, CancellationToken ct) => + documentSession.Add( + command.GuestStayId, + GuestStayAccount.CheckIn(command.GuestStayId, DateTimeOffset.UtcNow), + ct + ); + + public Task Handle(RecordCharge command, CancellationToken ct) => + documentSession.GetAndUpdate( + command.GuestStayId, + command.ExpectedVersion, + (GuestStayAccount state) => state.RecordCharge(command.Amount, DateTimeOffset.UtcNow), + ct + ); + + public Task Handle(RecordPayment command,CancellationToken ct) => + documentSession.GetAndUpdate( + command.GuestStayId, + command.ExpectedVersion, + (GuestStayAccount state) => state.RecordPayment(command.Amount, DateTimeOffset.UtcNow), + ct + ); + + public Task Handle(CheckOutGuest command, CancellationToken ct) => + documentSession.GetAndUpdate( + command.GuestStayId, + (GuestStayAccount state) => state.CheckOut(DateTimeOffset.UtcNow).FlatMap(), + ct + ); +} diff --git a/Sample/HotelManagement/HotelManagement/HotelManagement.csproj b/Sample/HotelManagement/HotelManagement/HotelManagement.csproj index bebf8238b..190456e75 100644 --- a/Sample/HotelManagement/HotelManagement/HotelManagement.csproj +++ b/Sample/HotelManagement/HotelManagement/HotelManagement.csproj @@ -14,9 +14,7 @@ - - - + diff --git a/Sample/HotelManagement/HotelManagement/ProcessManagers/GroupCheckouts/GroupCheckoutDomainService.cs b/Sample/HotelManagement/HotelManagement/ProcessManagers/GroupCheckouts/GroupCheckoutDomainService.cs new file mode 100644 index 000000000..2d4221b11 --- /dev/null +++ b/Sample/HotelManagement/HotelManagement/ProcessManagers/GroupCheckouts/GroupCheckoutDomainService.cs @@ -0,0 +1,62 @@ +using Core.Marten.ProcessManagers; +using Marten; + +namespace HotelManagement.ProcessManagers.GroupCheckouts; + +public record InitiateGroupCheckout( + Guid GroupCheckoutId, + Guid ClerkId, + Guid[] GuestStayIds +); + +public class GuestStayDomainService +{ + private readonly IDocumentSession documentSession; + + public GuestStayDomainService(IDocumentSession documentSession) => + this.documentSession = documentSession; + + public Task Handle(InitiateGroupCheckout command, CancellationToken ct) => + documentSession.Add( + command.GroupCheckoutId.ToString(), + GroupCheckoutProcessManager.Initiate( + command.GroupCheckoutId, + command.ClerkId, + command.GuestStayIds, + DateTimeOffset.UtcNow + ), + ct + ); + + public Task Handle(GroupCheckoutInitiated @event, CancellationToken ct) => + documentSession.GetAndUpdate( + @event.GroupCheckOutId.ToString(), + processManager => processManager.Handle(@event), + ct + ); + + public Task Handle(GuestStayAccounts.GuestCheckedOut @event, CancellationToken ct) + { + if (!@event.GroupCheckOutId.HasValue) + return Task.CompletedTask; + + return documentSession.GetAndUpdate( + @event.GroupCheckOutId.Value.ToString(), + processManager => processManager.Handle(@event), + ct + ); + } + + public Task Handle(GuestStayAccounts.GuestCheckoutFailed @event, CancellationToken ct) + { + if (!@event.GroupCheckOutId.HasValue) + return Task.CompletedTask; + + return documentSession.GetAndUpdate( + @event.GroupCheckOutId.Value.ToString(), + processManager => processManager.Handle(@event), + ct + ); + } + +} diff --git a/Sample/HotelManagement/HotelManagement/ProcessManagers/GroupCheckouts/GroupCheckoutProcessManager.cs b/Sample/HotelManagement/HotelManagement/ProcessManagers/GroupCheckouts/GroupCheckoutProcessManager.cs new file mode 100644 index 000000000..4e5a74b73 --- /dev/null +++ b/Sample/HotelManagement/HotelManagement/ProcessManagers/GroupCheckouts/GroupCheckoutProcessManager.cs @@ -0,0 +1,171 @@ +using Core.ProcessManagers; +using HotelManagement.ProcessManagers.GuestStayAccounts; +using Marten.Metadata; + +namespace HotelManagement.ProcessManagers.GroupCheckouts; + +public record GroupCheckoutInitiated( + Guid GroupCheckOutId, + Guid ClerkId, + Guid[] GuestStayIds, + DateTimeOffset InitiatedAt +); +public record GroupCheckoutCompleted( + Guid GroupCheckoutId, + Guid[] CompletedCheckouts, + DateTimeOffset CompletedAt +); + +public record GroupCheckoutFailed( + Guid GroupCheckoutId, + Guid[] CompletedCheckouts, + Guid[] FailedCheckouts, + DateTimeOffset FailedAt +); + +public enum CheckoutStatus +{ + Pending, + Initiated, + Completed, + Failed +} + +/// +/// This is an example of event-driven but not event-sourced process manager +/// +public class GroupCheckoutProcessManager: ProcessManager, IVersioned +{ + private Guid clerkId; + private readonly Dictionary guestStayCheckouts; + private CheckoutStatus status; + private DateTimeOffset initiatedAt; + private DateTimeOffset? completedAt; + private DateTimeOffset? failedAt; + + // For Marten Optimistic Concurrency + public new Guid Version { get; set; } + + private GroupCheckoutProcessManager( + Guid id, + Guid clerkId, + Dictionary guestStayCheckouts, + CheckoutStatus status, + DateTimeOffset initiatedAt, + DateTimeOffset? completedAt = null, + DateTimeOffset? failedAt = null + ) + { + Id = id; + this.clerkId = clerkId; + this.guestStayCheckouts = guestStayCheckouts; + this.status = status; + this.initiatedAt = initiatedAt; + this.completedAt = completedAt; + this.failedAt = failedAt; + } + + public static GroupCheckoutProcessManager Initiate( + Guid groupCheckoutId, + Guid clerkId, + Guid[] guestStayIds, + DateTimeOffset initiatedAt + ) + { + var processManager = new GroupCheckoutProcessManager( + groupCheckoutId, + clerkId, + guestStayIds.ToDictionary(id => id, _ => CheckoutStatus.Pending), + CheckoutStatus.Initiated, + initiatedAt + ); + + processManager.EnqueueEvent( + new GroupCheckoutInitiated(groupCheckoutId, clerkId, guestStayIds, initiatedAt)); + + return processManager; + } + + + public void Handle(GroupCheckoutInitiated @event) + { + if (status != CheckoutStatus.Initiated) + return; + + foreach (var guestStayAccountId in @event.GuestStayIds) + { + if (guestStayCheckouts[guestStayAccountId] != CheckoutStatus.Pending) continue; + + guestStayCheckouts[guestStayAccountId] = CheckoutStatus.Initiated; + + ScheduleCommand(new CheckOutGuest(guestStayAccountId, @event.GroupCheckOutId)); + } + } + + public void Handle(GuestCheckedOut @event) + { + if (guestStayCheckouts[@event.GuestStayId] == CheckoutStatus.Completed) + return; + + guestStayCheckouts[@event.GuestStayId] = CheckoutStatus.Completed; + + TryFinishCheckout(@event.CheckedOutAt); + } + + public void Handle(GuestCheckoutFailed @event) + { + if (guestStayCheckouts[@event.GuestStayId] == CheckoutStatus.Failed) + return; + + guestStayCheckouts[@event.GuestStayId] = CheckoutStatus.Failed; + + TryFinishCheckout(@event.FailedAt); + } + + private void TryFinishCheckout(DateTimeOffset now) + { + if (AreAnyOngoingCheckouts()) + return; + + if (AreAnyFailedCheckouts()) + { + status = CheckoutStatus.Failed; + failedAt = now; + + EnqueueEvent( + new GroupCheckoutFailed( + Id, + CheckoutsWith(CheckoutStatus.Completed), + CheckoutsWith(CheckoutStatus.Failed), + now + ) + ); + } + else + { + status = CheckoutStatus.Completed; + completedAt = now; + + EnqueueEvent( + new GroupCheckoutCompleted( + Id, + CheckoutsWith(CheckoutStatus.Completed), + now + ) + ); + } + } + + private bool AreAnyOngoingCheckouts() => + guestStayCheckouts.Values.Any(checkoutStatus => + checkoutStatus is CheckoutStatus.Initiated or CheckoutStatus.Pending); + + private bool AreAnyFailedCheckouts() => + guestStayCheckouts.Values.Any(checkoutStatus => checkoutStatus is CheckoutStatus.Failed); + + private Guid[] CheckoutsWith(CheckoutStatus checkoutStatus) => + guestStayCheckouts + .Where(pair => pair.Value == checkoutStatus) + .Select(pair => pair.Key) + .ToArray(); +} diff --git a/Sample/HotelManagement/HotelManagement/ProcessManagers/GuestStayAccounts/GuestStayAccount.cs b/Sample/HotelManagement/HotelManagement/ProcessManagers/GuestStayAccounts/GuestStayAccount.cs new file mode 100644 index 000000000..dffcc9762 --- /dev/null +++ b/Sample/HotelManagement/HotelManagement/ProcessManagers/GuestStayAccounts/GuestStayAccount.cs @@ -0,0 +1,110 @@ +using Core.Aggregates; +using Core.Structures; +using Marten.Metadata; +using static Core.Structures.Result; + +namespace HotelManagement.ProcessManagers.GuestStayAccounts; + +public record GuestCheckedIn( + Guid GuestStayId, + DateTimeOffset CheckedInAt +); + +public record GuestCheckedOut( + Guid GuestStayId, + DateTimeOffset CheckedOutAt, + Guid? GroupCheckOutId = null +); + +public record GuestCheckoutFailed( + Guid GuestStayId, + GuestCheckoutFailed.FailureReason Reason, + DateTimeOffset FailedAt, + Guid? GroupCheckOutId = null +) +{ + public enum FailureReason + { + NotOpened, + BalanceNotSettled + } +} + +/// +/// This is an example of event-driven but not event-sourced aggregate +/// +public class GuestStayAccount: Aggregate, IVersioned +{ + private DateTimeOffset checkedInDate; + private decimal balance; + private GuestStayAccountStatus status; + private DateTimeOffset? checkedOutDate; + private bool IsSettled => balance == 0; + + // For Marten Optimistic Concurrency + public new Guid Version { get; set; } + + private GuestStayAccount( + Guid id, + DateTimeOffset checkedInDate, + decimal balance = 0, + GuestStayAccountStatus status = GuestStayAccountStatus.Opened + ) + { + Id = id; + this.checkedInDate = checkedInDate; + this.balance = balance; + this.status = status; + } + + public static GuestStayAccount CheckIn(Guid guestStayId, DateTimeOffset now) => new(guestStayId, now); + + public void RecordCharge(decimal amount, DateTimeOffset now) + { + if (status != GuestStayAccountStatus.Opened) + throw new InvalidOperationException("Cannot record charge for not opened account"); + + balance -= amount; + } + + public void RecordPayment(decimal amount, DateTimeOffset now) + { + if (status != GuestStayAccountStatus.Opened) + throw new InvalidOperationException("Cannot record charge for not opened account"); + + balance += amount; + } + + 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; + checkedOutDate = now; + + Enqueue( + new GuestCheckedOut( + Id, + now, + groupCheckoutId + ) + ); + } +} + +public enum GuestStayAccountStatus +{ + Opened = 1, + CheckedOut = 2 +} diff --git a/Sample/HotelManagement/HotelManagement/Saga/GuestStayAccounts/GuestStayAccountDomainService.cs b/Sample/HotelManagement/HotelManagement/ProcessManagers/GuestStayAccounts/GuestStayAccountDomainService.cs similarity index 80% rename from Sample/HotelManagement/HotelManagement/Saga/GuestStayAccounts/GuestStayAccountDomainService.cs rename to Sample/HotelManagement/HotelManagement/ProcessManagers/GuestStayAccounts/GuestStayAccountDomainService.cs index f38377e1f..5826b8c8a 100644 --- a/Sample/HotelManagement/HotelManagement/Saga/GuestStayAccounts/GuestStayAccountDomainService.cs +++ b/Sample/HotelManagement/HotelManagement/ProcessManagers/GuestStayAccounts/GuestStayAccountDomainService.cs @@ -1,7 +1,7 @@ -using Core.Marten.Extensions; +using Core.Marten.Aggregates; using Marten; -namespace HotelManagement.Saga.GuestStayAccounts; +namespace HotelManagement.ProcessManagers.GuestStayAccounts; public record CheckInGuest( Guid GuestStayId @@ -30,32 +30,30 @@ public GuestStayDomainService(IDocumentSession documentSession) => this.documentSession = documentSession; public Task Handle(CheckInGuest command, CancellationToken ct) => - documentSession.Add( - command.GuestStayId, + documentSession.Add( + command.GuestStayId.ToString(), GuestStayAccount.CheckIn(command.GuestStayId, DateTimeOffset.UtcNow), ct ); public Task Handle(RecordCharge command, int expectedVersion, CancellationToken ct) => documentSession.GetAndUpdate( - command.GuestStayId, - expectedVersion, + command.GuestStayId.ToString(), (GuestStayAccount state) => state.RecordCharge(command.Amount, DateTimeOffset.UtcNow), ct ); public Task Handle(RecordPayment command, int expectedVersion, CancellationToken ct) => documentSession.GetAndUpdate( - command.GuestStayId, - expectedVersion, + command.GuestStayId.ToString(), (GuestStayAccount state) => state.RecordPayment(command.Amount, DateTimeOffset.UtcNow), ct ); public Task Handle(CheckOutGuest command, CancellationToken ct) => documentSession.GetAndUpdate( - command.GuestStayId, - (GuestStayAccount state) => state.CheckOut(DateTimeOffset.UtcNow).FlatMap(), + command.GuestStayId.ToString(), + (GuestStayAccount state) => state.CheckOut(DateTimeOffset.UtcNow), ct ); } diff --git a/Sample/HotelManagement/HotelManagement/Saga/GroupCheckouts/GroupCheckout.cs b/Sample/HotelManagement/HotelManagement/Sagas/GroupCheckouts/GroupCheckout.cs similarity index 99% rename from Sample/HotelManagement/HotelManagement/Saga/GroupCheckouts/GroupCheckout.cs rename to Sample/HotelManagement/HotelManagement/Sagas/GroupCheckouts/GroupCheckout.cs index 5b0012162..c1f476853 100644 --- a/Sample/HotelManagement/HotelManagement/Saga/GroupCheckouts/GroupCheckout.cs +++ b/Sample/HotelManagement/HotelManagement/Sagas/GroupCheckouts/GroupCheckout.cs @@ -1,7 +1,7 @@ using Core.Extensions; using Core.Structures; -namespace HotelManagement.Saga.GroupCheckouts; +namespace HotelManagement.Sagas.GroupCheckouts; public record GroupCheckoutInitiated( Guid GroupCheckoutId, diff --git a/Sample/HotelManagement/HotelManagement/Saga/GroupCheckouts/GroupCheckoutDomainService.cs b/Sample/HotelManagement/HotelManagement/Sagas/GroupCheckouts/GroupCheckoutDomainService.cs similarity index 86% rename from Sample/HotelManagement/HotelManagement/Saga/GroupCheckouts/GroupCheckoutDomainService.cs rename to Sample/HotelManagement/HotelManagement/Sagas/GroupCheckouts/GroupCheckoutDomainService.cs index 04e97746e..227035157 100644 --- a/Sample/HotelManagement/HotelManagement/Saga/GroupCheckouts/GroupCheckoutDomainService.cs +++ b/Sample/HotelManagement/HotelManagement/Sagas/GroupCheckouts/GroupCheckoutDomainService.cs @@ -1,7 +1,8 @@ +using Core.Commands; using Core.Marten.Extensions; using Marten; -namespace HotelManagement.Saga.GroupCheckouts; +namespace HotelManagement.Sagas.GroupCheckouts; public record InitiateGroupCheckout( Guid GroupCheckoutId, @@ -26,7 +27,11 @@ public record RecordGuestCheckoutFailure( DateTimeOffset FailedAt ); -public class GuestStayDomainService +public class GuestStayDomainService: + ICommandHandler, + ICommandHandler, + ICommandHandler, + ICommandHandler { private readonly IDocumentSession documentSession; diff --git a/Sample/HotelManagement/HotelManagement/Saga/GroupCheckouts/GroupCheckoutSaga.cs b/Sample/HotelManagement/HotelManagement/Sagas/GroupCheckouts/GroupCheckoutSaga.cs similarity index 83% rename from Sample/HotelManagement/HotelManagement/Saga/GroupCheckouts/GroupCheckoutSaga.cs rename to Sample/HotelManagement/HotelManagement/Sagas/GroupCheckouts/GroupCheckoutSaga.cs index d02607399..9203bb67f 100644 --- a/Sample/HotelManagement/HotelManagement/Saga/GroupCheckouts/GroupCheckoutSaga.cs +++ b/Sample/HotelManagement/HotelManagement/Sagas/GroupCheckouts/GroupCheckoutSaga.cs @@ -1,9 +1,13 @@ using Core.Commands; -using HotelManagement.Saga.GuestStayAccounts; +using Core.Events; +using HotelManagement.Sagas.GuestStayAccounts; -namespace HotelManagement.Saga.GroupCheckouts; +namespace HotelManagement.Sagas.GroupCheckouts; -public class GroupCheckoutSaga +public class GroupCheckoutSaga: + IEventHandler, + IEventHandler, + IEventHandler { private readonly IAsyncCommandBus commandBus; diff --git a/Sample/HotelManagement/HotelManagement/Saga/GuestStayAccounts/GuestStayAccount.cs b/Sample/HotelManagement/HotelManagement/Sagas/GuestStayAccounts/GuestStayAccount.cs similarity index 98% rename from Sample/HotelManagement/HotelManagement/Saga/GuestStayAccounts/GuestStayAccount.cs rename to Sample/HotelManagement/HotelManagement/Sagas/GuestStayAccounts/GuestStayAccount.cs index 62ca88741..434a0c4d4 100644 --- a/Sample/HotelManagement/HotelManagement/Saga/GuestStayAccounts/GuestStayAccount.cs +++ b/Sample/HotelManagement/HotelManagement/Sagas/GuestStayAccounts/GuestStayAccount.cs @@ -1,7 +1,7 @@ using Core.Structures; using static Core.Structures.Result; -namespace HotelManagement.Saga.GuestStayAccounts; +namespace HotelManagement.Sagas.GuestStayAccounts; public record GuestCheckedIn( Guid GuestStayId, diff --git a/Sample/HotelManagement/HotelManagement/Sagas/GuestStayAccounts/GuestStayAccountDomainService.cs b/Sample/HotelManagement/HotelManagement/Sagas/GuestStayAccounts/GuestStayAccountDomainService.cs new file mode 100644 index 000000000..46bb262aa --- /dev/null +++ b/Sample/HotelManagement/HotelManagement/Sagas/GuestStayAccounts/GuestStayAccountDomainService.cs @@ -0,0 +1,68 @@ +using Core.Commands; +using Core.Marten.Extensions; +using Marten; + +namespace HotelManagement.Sagas.GuestStayAccounts; + +public record CheckInGuest( + Guid GuestStayId +); + +public record RecordCharge( + Guid GuestStayId, + decimal Amount, + int ExpectedVersion +); + +public record RecordPayment( + Guid GuestStayId, + decimal Amount, + int ExpectedVersion +); + +public record CheckOutGuest( + Guid GuestStayId, + Guid? GroupCheckOutId = null +); + +public class GuestStayDomainService: + ICommandHandler, + ICommandHandler, + ICommandHandler, + ICommandHandler +{ + private readonly IDocumentSession documentSession; + + public GuestStayDomainService(IDocumentSession documentSession) => + this.documentSession = documentSession; + + public Task Handle(CheckInGuest command, CancellationToken ct) => + documentSession.Add( + command.GuestStayId, + GuestStayAccount.CheckIn(command.GuestStayId, DateTimeOffset.UtcNow), + ct + ); + + public Task Handle(RecordCharge command, CancellationToken ct) => + documentSession.GetAndUpdate( + command.GuestStayId, + command.ExpectedVersion, + (GuestStayAccount state) => state.RecordCharge(command.Amount, DateTimeOffset.UtcNow), + ct + ); + + public Task Handle(RecordPayment command,CancellationToken ct) => + documentSession.GetAndUpdate( + command.GuestStayId, + command.ExpectedVersion, + (GuestStayAccount state) => state.RecordPayment(command.Amount, DateTimeOffset.UtcNow), + ct + ); + + public Task Handle(CheckOutGuest command, CancellationToken ct) => + documentSession.GetAndUpdate( + command.GuestStayId, + (GuestStayAccount state) => state.CheckOut(DateTimeOffset.UtcNow).FlatMap(), + ct + ); +}