diff --git a/csharp-es/Domain/Account.cs b/csharp-es/Domain/Account.cs new file mode 100644 index 0000000..4a744d7 --- /dev/null +++ b/csharp-es/Domain/Account.cs @@ -0,0 +1,93 @@ +using CsharpEs.Library; + +namespace CsharpEs.Domain; + +public sealed record AccountState(Guid Id); + +public abstract record AccountCommand +{ + public sealed record OpenAccount(Guid AccountId, Money OpeningBalance) : AccountCommand; + + public sealed record WithdrawMoney(Guid AccountId, Money Amount) : AccountCommand; + + public sealed record DepositMoney(Guid AccountId, Money Amount) : AccountCommand; +} + +public abstract record AccountEvent(Guid AccountId) +{ + public sealed record AccountOpened(Guid AccountId, Money OpeningBalance) + : AccountEvent(AccountId); + + public sealed record MoneyWithdrawn(Guid AccountId, Money Amount) : AccountEvent(AccountId); + + public sealed record MoneyDeposited(Guid AccountId, Money Amount) : AccountEvent(AccountId); +} + +public abstract record AccountError +{ + public sealed record AccountNotFound : AccountError; + + public sealed record AccountOpenAlready : AccountError; + + public sealed record OpeningBalanceMustBeNonNegative : AccountError; + + public sealed record InnerException(Exception exception) : AccountError; +} + +public static class AccountDecider +{ + public static Result Decide( + AccountState? state, + AccountCommand command + ) => + Result.Catch( + () => + (state, command) switch + { + // if this is a new account, check for valid opening balance + (null, AccountCommand.OpenAccount c) when c.OpeningBalance.Amount < 0m => + Result.Fail( + new AccountError.OpeningBalanceMustBeNonNegative() + ), + + // still a new account, now we can open it + (null, AccountCommand.OpenAccount c) => Result.Ok( + new AccountEvent.AccountOpened(c.AccountId, c.OpeningBalance) + ), + + // if we have an account already, you can't open it + (not null, AccountCommand.OpenAccount c) => Result< + AccountEvent, + AccountError + >.Fail(new AccountError.AccountOpenAlready()), + + (null, _) => Result.Fail( + new AccountError.AccountNotFound() + ), + + (_, AccountCommand.WithdrawMoney c) => Result.Ok( + new AccountEvent.MoneyWithdrawn(c.AccountId, c.Amount) + ), + + (_, AccountCommand.DepositMoney c) => Result.Ok( + new AccountEvent.MoneyDeposited(c.AccountId, c.Amount) + ), + + _ => throw new InvalidOperationException("Unknown command."), + }, + e => new AccountError.InnerException(e) + ); + + public static Result Evolve( + AccountState? state, + AccountEvent @event + ) => + (state, @event) switch + { + (null, AccountEvent.AccountOpened e) => Result.Ok( + new AccountState(e.AccountId) + ), + + _ => Result.Ok(state), + }; +} diff --git a/csharp-es/Domain/Money.cs b/csharp-es/Domain/Money.cs new file mode 100644 index 0000000..c776726 --- /dev/null +++ b/csharp-es/Domain/Money.cs @@ -0,0 +1,3 @@ +namespace CsharpEs.Domain; + +public sealed record Money(decimal Amount); diff --git a/csharp-es/Infrastructure/AccountReadModel.cs b/csharp-es/Infrastructure/AccountReadModel.cs new file mode 100644 index 0000000..e292d7f --- /dev/null +++ b/csharp-es/Infrastructure/AccountReadModel.cs @@ -0,0 +1,108 @@ +using System.Collections.Immutable; +using CsharpEs.Domain; +using CsharpEs.Library; + +namespace CsharpEs.Infrastructure; + +public abstract record ReadModelError +{ + public sealed record AccountDetailsNotFound : ReadModelError; +} + +public sealed record AccountBalanceReadModel( + Func> Project, + Func[], ReadModelError>> Query +); + +[Flags] +public enum AccountFlags +{ + None = 0, + InOverdraft = 1, +} + +public sealed record AccountDetails(Money Balance, AccountFlags Flags); + +public static class AccountBalanceReadModelModule +{ + public static Result Create() + { + // This is our read-model specific "persistent" storage + var modelData = ImmutableDictionary.Empty; + + Result Project(AccountEvent @event) + { + Logging.Output("rm", "Projecting event", @event); + switch (@event) + { + case AccountEvent.AccountOpened e: + modelData = modelData.SetItem( + e.AccountId, + new AccountDetails(e.OpeningBalance, AccountFlags.None) + ); + break; + + case AccountEvent.MoneyWithdrawn e: + { + if (modelData.TryGetValue(e.AccountId, out var oldDetails)) + { + var newBalance = new Money(oldDetails.Balance.Amount - e.Amount.Amount); + modelData = modelData.SetItem( + e.AccountId, + oldDetails with + { + Balance = newBalance, + Flags = CalcAccountFlags(newBalance, oldDetails), + } + ); + } + else + return Result.Fail( + new ReadModelError.AccountDetailsNotFound() + ); + } + break; + + case AccountEvent.MoneyDeposited e: + { + if (modelData.TryGetValue(e.AccountId, out var oldDetails)) + { + var newBalance = new Money(oldDetails.Balance.Amount + e.Amount.Amount); + modelData = modelData.SetItem( + e.AccountId, + oldDetails with + { + Balance = newBalance, + Flags = CalcAccountFlags(newBalance, oldDetails), + } + ); + } + else + return Result.Fail( + new ReadModelError.AccountDetailsNotFound() + ); + } + break; + } + + // Read model-side "project" may not normally return any data, but for demo + // purposes we return the affected data directly to save us modeling a separate + // query side. + return Result.Ok(modelData[@event.AccountId]); + } + + Result[], ReadModelError> Query() => + Result[], ReadModelError>.Ok(modelData.ToArray()); + + return Result.Ok( + new AccountBalanceReadModel(Project, Query) + ); + } + + private static AccountFlags CalcAccountFlags(Money newBalance, AccountDetails oldDetails) + { + return newBalance.Amount < 0 + ? oldDetails.Flags | AccountFlags.InOverdraft + : oldDetails.Flags & ~AccountFlags.InOverdraft; + } +} diff --git a/csharp-es/Library/Logging.cs b/csharp-es/Library/Logging.cs new file mode 100644 index 0000000..ce58f5f --- /dev/null +++ b/csharp-es/Library/Logging.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CsharpEs.Library; + +public static class Logging +{ + public static T LogReturn(string message, T r) + { + Console.WriteLine(message); + return r; + } + + static string Indent(string text, int indent = 4) + { + var prefix = new string(' ', indent); + + return Environment.NewLine + + string.Join( + Environment.NewLine, + text.Split(Environment.NewLine).Select(line => prefix + line) + ); + } + + public static string Format(Object? o) => + o != null + ? Indent( + JsonSerializer.Serialize( + o, + new JsonSerializerOptions + { + WriteIndented = true, + Converters = { new JsonStringEnumConverter() }, + } + ) + ) + : "(null)"; + + public static void Output(string src, string message, Object? x) + { + Console.WriteLine($"[{src}] {message} | {Format(x)}"); + } + + public static void Output(string src, string message) + { + Console.WriteLine($"[{src}] {message}"); + } + + public static Action OutputDelegate(string src, string message) => + x => + { + Output(src, message, x); + }; + + public static void OutputError(string src, T error) + { + Console.Error.WriteLine($"\e[1;31m[{src} ERROR]\e[0m {Format(error)}"); + } +} diff --git a/csharp-es/Library/Result.cs b/csharp-es/Library/Result.cs new file mode 100644 index 0000000..57f55e7 --- /dev/null +++ b/csharp-es/Library/Result.cs @@ -0,0 +1,138 @@ +namespace CsharpEs.Library; + +public readonly record struct Result +{ + private readonly T _value; + + private readonly E _error; + + public bool IsSuccess { get; } + + public bool IsFailure => !IsSuccess; + + public T Value => + IsSuccess + ? _value + : throw new InvalidOperationException("No value present for failed result."); + + public E Error => + IsFailure + ? _error + : throw new InvalidOperationException("No error present for successful result."); + + private Result(T value) + { + _value = value; + _error = default!; + IsSuccess = true; + } + + private Result(E error) + { + _value = default!; + _error = error; + IsSuccess = false; + } + + public static Result Ok(T value) => new(value); + + public static Result Fail(E error) => new(error); + + public TResult Match(Func onSuccess, Func onFailure) => + IsSuccess ? onSuccess(_value) : onFailure(_error); + + public void Switch(Action onSuccess, Action onFailure) + { + if (IsSuccess) + onSuccess(_value); + else + onFailure(_error); + } + + public static Result Catch(Func f, Func exceptionMapper) + { + try + { + var result = f(); + return Result.Ok(result); + } + catch (Exception e) + { + return Result.Fail(exceptionMapper(e)); + } + } + + public static Result Catch(Func> f, Func exceptionMapper) + { + try + { + return f(); + } + catch (Exception e) + { + return Result.Fail(exceptionMapper(e)); + } + } +} + +public static class ResultExtensions +{ + public static Result Bind( + this Result result, + Func> binder + ) => result.IsSuccess ? binder(result.Value) : Result.Fail(result.Error); + + public static Result Map( + this Result result, + Func mapper + ) => + result.IsSuccess + ? Result.Ok(mapper(result.Value)) + : Result.Fail(result.Error); + + public static Result Tap(this Result result, Action action) + { + if (result.IsSuccess) + action(result.Value); + + return result; + } + + public static Result TapError(this Result result, Action action) + { + if (result.IsFailure) + action(result.Error); + + return result; + } + + public static Result MapError( + this Result result, + Func map + ) => + result.IsSuccess + ? Result.Ok(result.Value) + : Result.Fail(map(result.Error)); + + public static Result Log(this Result result, string src, string msg) => + Tap(result, Logging.OutputDelegate(src, msg)).TapError(m => Logging.OutputError(src, m)); + + public static Result Log( + this Result result, + string src, + Func renderText + ) => + Tap(result, x => Logging.OutputDelegate(src, renderText(x))(x)) + .TapError(m => Logging.OutputError(src, m)); + + public static Result Select( + this Result result, + Func mapper + ) => result.Map(mapper); + + public static Result SelectMany( + this Result result, + Func> binder, + Func projector + ) => result.Bind(x => binder(x).Map(y => projector(x, y))); +} diff --git a/csharp-es/Program.cs b/csharp-es/Program.cs new file mode 100644 index 0000000..83132dd --- /dev/null +++ b/csharp-es/Program.cs @@ -0,0 +1,84 @@ +using System.Collections.Immutable; +using CsharpEs.Domain; +using CsharpEs.Infrastructure; +using CsharpEs.Library; + +public abstract record DemoError +{ + public sealed record Account(AccountError Error) : DemoError; + + public sealed record ReadModel(ReadModelError Error) : DemoError; +} + +public class Program +{ + private static Guid accountId; + private static ImmutableList demoCommands = ImmutableList.Empty; + + static Program() + { + accountId = Guid.NewGuid(); + demoCommands = demoCommands.AddRange([ + new AccountCommand.OpenAccount(accountId, new Money(-200m)), + // new AccountCommand.OpenAccount(accountId, new Money(200m)), + new AccountCommand.WithdrawMoney(accountId, new Money(100m)), + new AccountCommand.WithdrawMoney(accountId, new Money(200m)), + new AccountCommand.DepositMoney(accountId, new Money(500m)), + ]); + } + + static ImmutableList eventStore = ImmutableList.Empty; + + static Result ApplyEvent( + AccountBalanceReadModel balanceProjection, + AccountState? state, + AccountEvent @event + ) + { + eventStore = eventStore.Add(@event); + Logging.Output( + "ae", + $"Applying event {Logging.Format(@event)} to state {Logging.Format(state)}" + ); + return AccountDecider + .Evolve(state, @event) + .MapError(e => (DemoError)new DemoError.Account(e)) + .Bind(newState => + balanceProjection + .Project(@event) + .MapError(e => (DemoError)new DemoError.ReadModel(e)) + .Log("ae ad", "Account details") + .Map(_ => newState) + ) + .Log("ae", $"Applied and projected {Logging.Format(@event)}"); + } + + public static void Main(string[] args) + { + Logging.Output("csharp-es", "Starting money handling demo..."); + + AccountBalanceReadModelModule + .Create() + .MapError(e => (DemoError)new DemoError.ReadModel(e)) + .Bind(readModel => + demoCommands + .Aggregate( + Result.Ok(null), + (stateResult, command) => + stateResult.Bind(state => + AccountDecider + .Decide(state, command) + .MapError(e => (DemoError)new DemoError.Account(e)) + .Bind(@event => ApplyEvent(readModel, state, @event)) + ) + ) + .Bind(s => + readModel + .Query() + .Log("query", "Read model data") + .MapError(e => (DemoError)new DemoError.ReadModel(e)) + ) + ) + .Log("csharp-es done", "Demo completed."); + } +} diff --git a/csharp-es/csharp-es.csproj b/csharp-es/csharp-es.csproj new file mode 100644 index 0000000..a955afb --- /dev/null +++ b/csharp-es/csharp-es.csproj @@ -0,0 +1,9 @@ + + + Exe + net10.0 + CsharpEs + enable + enable + + diff --git a/csharp.sln b/csharp.sln index c9767a6..7bc0312 100644 --- a/csharp.sln +++ b/csharp.sln @@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "csharp-fp3", "csharp-fp3\cs EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "csharp-fp3.Tests", "csharp-fp3.Tests\csharp-fp3.Tests.csproj", "{B85327F8-AFA8-47E9-97AB-318F74238929}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "csharp-es", "csharp-es\csharp-es.csproj", "{748A2E36-A131-4EAA-A403-E54E347010C0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -143,6 +145,18 @@ Global {B85327F8-AFA8-47E9-97AB-318F74238929}.Release|x64.Build.0 = Release|Any CPU {B85327F8-AFA8-47E9-97AB-318F74238929}.Release|x86.ActiveCfg = Release|Any CPU {B85327F8-AFA8-47E9-97AB-318F74238929}.Release|x86.Build.0 = Release|Any CPU + {748A2E36-A131-4EAA-A403-E54E347010C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {748A2E36-A131-4EAA-A403-E54E347010C0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {748A2E36-A131-4EAA-A403-E54E347010C0}.Debug|x64.ActiveCfg = Debug|Any CPU + {748A2E36-A131-4EAA-A403-E54E347010C0}.Debug|x64.Build.0 = Debug|Any CPU + {748A2E36-A131-4EAA-A403-E54E347010C0}.Debug|x86.ActiveCfg = Debug|Any CPU + {748A2E36-A131-4EAA-A403-E54E347010C0}.Debug|x86.Build.0 = Debug|Any CPU + {748A2E36-A131-4EAA-A403-E54E347010C0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {748A2E36-A131-4EAA-A403-E54E347010C0}.Release|Any CPU.Build.0 = Release|Any CPU + {748A2E36-A131-4EAA-A403-E54E347010C0}.Release|x64.ActiveCfg = Release|Any CPU + {748A2E36-A131-4EAA-A403-E54E347010C0}.Release|x64.Build.0 = Release|Any CPU + {748A2E36-A131-4EAA-A403-E54E347010C0}.Release|x86.ActiveCfg = Release|Any CPU + {748A2E36-A131-4EAA-A403-E54E347010C0}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -157,5 +171,6 @@ Global {4AFF063B-7125-4783-BE84-E1E9B4E2A462} = {89C53051-77F9-4C1C-ABE5-6D54AB398471} {83AFEF87-AAC9-45F4-B803-1BCDF0443BF7} = {89C53051-77F9-4C1C-ABE5-6D54AB398471} {B85327F8-AFA8-47E9-97AB-318F74238929} = {89C53051-77F9-4C1C-ABE5-6D54AB398471} + {748A2E36-A131-4EAA-A403-E54E347010C0} = {89C53051-77F9-4C1C-ABE5-6D54AB398471} EndGlobalSection EndGlobal