Skip to content

Commit

Permalink
Added sample business logic and conventional type name mapper
Browse files Browse the repository at this point in the history
  • Loading branch information
oskardudycz committed Nov 5, 2024
1 parent 4612f03 commit 2293cc7
Show file tree
Hide file tree
Showing 7 changed files with 255 additions and 28 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace HotelManagement.EventStore;

public class CommandHandler<T, TEvent>(
IEventStore eventStore,
Func<T, TEvent, T> evolve,
Func<T> getInitial
) where TEvent : notnull
{
public async Task GetAndUpdate(
Guid id,
Func<T, TEvent[]> handle,
CancellationToken ct
)
{
var events = await eventStore.ReadStream<TEvent>(id, ct);

var state = events.Aggregate(getInitial(), evolve);

var result = handle(state);

if(result.Length > 0)
await eventStore.AppendToStream(id, result.Cast<object>(), ct);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ public class EventSerializer(EventTypeMapping mapping, EventTransformations tran
public object? Deserialize(string eventTypeName, string json) =>
transformations.TryTransform(eventTypeName, json, out var transformed)
? transformed
: JsonSerializer.Deserialize(json, mapping.Map(eventTypeName));
: JsonSerializer.Deserialize(json, mapping.ToType(eventTypeName)!);
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,47 @@
namespace HotelManagement.EventStore;
using System.Collections.Concurrent;

namespace HotelManagement.EventStore;

public class EventTypeMapping
{
private readonly Dictionary<string, Type> mappings = new();
private readonly ConcurrentDictionary<string, Type?> typeMap = new();
private readonly ConcurrentDictionary<Type, string> typeNameMap = new();

public void AddCustomMap<T>(string eventTypeName) => AddCustomMap(typeof(T), eventTypeName);

public EventTypeMapping Register<TEvent>(params string[] typeNames)
public void AddCustomMap(Type eventType, string eventTypeName)
{
var eventType = typeof(TEvent);
typeNameMap.AddOrUpdate(eventType, eventTypeName, (_, typeName) => typeName);
typeMap.AddOrUpdate(eventTypeName, eventType, (_, type) => type);
}

foreach (var typeName in typeNames)
public string ToName<TEventType>() => ToName(typeof(TEventType));

public string ToName(Type eventType) =>
typeNameMap.GetOrAdd(eventType, _ =>
{
mappings.Add(typeName, eventType);
}
var eventTypeName = eventType.FullName!;

return this;
}
typeMap.TryAdd(eventTypeName, eventType);

return eventTypeName;
});

public Type? ToType(string eventTypeName) =>
typeMap.GetOrAdd(eventTypeName, _ =>
{
var type = GetFirstMatchingTypeFromCurrentDomainAssembly(eventTypeName);

if (type == null)
return null;

typeNameMap.TryAdd(type, eventTypeName);

return type;
});

public Type Map(string eventType) => mappings[eventType];
private static Type? GetFirstMatchingTypeFromCurrentDomainAssembly(string typeName) =>
AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a => a.GetTypes().Where(x => x.FullName == typeName || x.Name == typeName))
.FirstOrDefault();
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,40 @@ namespace HotelManagement.EventStore;

public interface IEventStore
{
ValueTask AppendToStream(Guid streamId, IEnumerable<object> newEvents, CancellationToken ct = default);
ValueTask<TEvent[]> ReadStream<TEvent>(Guid streamId, CancellationToken ct = default) where TEvent : notnull;
ValueTask AppendToStream(
Guid streamId,
IEnumerable<object> newEvents,
CancellationToken ct = default
);

ValueTask<TEvent[]> ReadStream<TEvent>(
Guid streamId,
CancellationToken ct = default
) where TEvent : notnull;
}

public record EventMetadata(
Guid CorrelationId
);

public record SerializedEvent(
string EventType,
string Data,
string MetaData = ""
);

public class InMemoryEventStore: IEventStore
{
private readonly Dictionary<Guid, List<(string EventType, string Json)>> events = new();
private readonly Dictionary<Guid, List<SerializedEvent>> events = new();

public ValueTask AppendToStream(Guid streamId, IEnumerable<object> newEvents, CancellationToken _ = default)
{
if (!events.ContainsKey(streamId))
events[streamId] = [];

var serializedEvents = newEvents.Select(e => (e.GetType().FullName!, JsonSerializer.Serialize(e)));
var serializedEvents = newEvents.Select(e =>
new SerializedEvent(e.GetType().FullName!, JsonSerializer.Serialize(e))
);

events[streamId].AddRange(serializedEvents);

Expand All @@ -33,7 +53,7 @@ public ValueTask<TEvent[]> ReadStream<TEvent>(Guid streamId, CancellationToken _
var deserializedEvents = streamEvents
.Select(@event =>
Type.GetType(@event.EventType, true) is { } clrEventType
? JsonSerializer.Deserialize(@event.Json, clrEventType)
? JsonSerializer.Deserialize(@event.Data, clrEventType)
: null
)
.Where(e => e != null)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
namespace HotelManagement.EventStore;

public record EventMetadata(
Guid CorrelationId
);

public record EventData(
string EventType,
string Data,
string MetaData
);

public class StreamTransformations
{
private readonly List<Func<List<EventData>, List<EventData>>> jsonTransformations = [];
private readonly List<Func<List<SerializedEvent>, List<SerializedEvent>>> jsonTransformations = [];

public List<EventData> Transform(List<EventData> events)
public List<SerializedEvent> Transform(List<SerializedEvent> events)
{
if (!jsonTransformations.Any())
return events;
Expand All @@ -26,7 +17,7 @@ public List<EventData> Transform(List<EventData> events)
}

public StreamTransformations Register(
Func<List<EventData>, List<EventData>> transformJson
Func<List<SerializedEvent>, List<SerializedEvent>> transformJson
)
{
jsonTransformations.Add(transformJson);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
namespace HotelManagement.GuestStayAccounts;

using static GuestStayAccountEvent;
using static GuestStayAccountCommand;

public abstract record GuestStayAccountCommand
{
public record CheckIn(
string ClerkId,
string GuestStayId,
string RoomId,
DateTimeOffset Now
): GuestStayAccountCommand;

public record RecordCharge(
string GuestStayAccountId,
decimal Amount,
DateTimeOffset Now
): GuestStayAccountCommand;

public record RecordPayment(
string GuestStayAccountId,
decimal Amount,
DateTimeOffset Now
): GuestStayAccountCommand;

public record CheckOut(
string ClerkId,
string GuestStayAccountId,
DateTimeOffset Now
): GuestStayAccountCommand;

private GuestStayAccountCommand() { }
}

public static class GuestStayAccountDecider
{
public static GuestCheckedIn CheckIn(CheckIn command, GuestStayAccount state) =>
new GuestCheckedIn(
$"{command.GuestStayId}:{command.RoomId}:{command.Now.Date:yyyy-MM-dd}",
command.GuestStayId,
command.RoomId,
command.ClerkId,
command.Now
);

public static ChargeRecorded RecordCharge(RecordCharge command, GuestStayAccount state)
{
if (state.Status != GuestStayAccountStatus.Opened)
throw new InvalidOperationException("Cannot record charge for not opened account");

return new ChargeRecorded(state.Id, command.Amount, command.Now);
}

public static PaymentRecorded RecordPayment(RecordPayment command, GuestStayAccount state)
{
if (state.Status != GuestStayAccountStatus.Opened)
throw new InvalidOperationException("Cannot record charge for not opened account");

return new PaymentRecorded(state.Id, command.Amount, command.Now);
}

public static GuestStayAccountEvent CheckOut(CheckOut command, GuestStayAccount state)
{
if (state.Status != GuestStayAccountStatus.Opened)
return new GuestCheckoutFailed(
state.Id,
command.ClerkId,
GuestCheckoutFailed.FailureReason.NotOpened,
command.Now
);

return state.IsSettled
? new GuestCheckedOut(
state.Id,
command.ClerkId,
command.Now
)
: new GuestCheckoutFailed(
state.Id,
command.ClerkId,
GuestCheckoutFailed.FailureReason.BalanceNotSettled,
command.Now
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
namespace HotelManagement.GuestStayAccounts;

using static GuestStayAccountEvent;

public record GuestStayAccount(
string Id,
decimal Balance = 0,
GuestStayAccountStatus Status = GuestStayAccountStatus.Opened
)
{
public bool IsSettled => Balance == 0;

public GuestStayAccount Evolve(GuestStayAccountEvent @event) =>
@event switch
{
GuestCheckedIn checkedIn => this with
{
Id = checkedIn.GuestStayAccountId, 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);
}

public enum GuestStayAccountStatus
{
Opened = 1,
CheckedOut = 2
}

public abstract record GuestStayAccountEvent
{
public record GuestCheckedIn(
string GuestStayAccountId,
string GuestStayId,
string RoomId,
string ClerkId,
DateTimeOffset CheckedInAt
): GuestStayAccountEvent;

public record ChargeRecorded(
string GuestStayAccountId,
decimal Amount,
DateTimeOffset RecordedAt
): GuestStayAccountEvent;

public record PaymentRecorded(
string GuestStayAccountId,
decimal Amount,
DateTimeOffset RecordedAt
): GuestStayAccountEvent;

public record GuestCheckedOut(
string GuestStayAccountId,
string ClerkId,
DateTimeOffset CheckedOutAt
): GuestStayAccountEvent;

public record GuestCheckoutFailed(
string GuestStayAccountId,
string ClerkId,
GuestCheckoutFailed.FailureReason Reason,
DateTimeOffset FailedAt
): GuestStayAccountEvent
{
public enum FailureReason
{
NotOpened,
BalanceNotSettled
}
}

private GuestStayAccountEvent(){}
}

0 comments on commit 2293cc7

Please sign in to comment.