diff --git a/csharp-fp3/Application/AccountApplication.cs b/csharp-fp3/Application/AccountApplication.cs new file mode 100644 index 0000000..7dd66a0 --- /dev/null +++ b/csharp-fp3/Application/AccountApplication.cs @@ -0,0 +1,32 @@ +using CsharpFp3.Domain; +using CsharpFp3.Infrastructure; +using CsharpFp3.Library; + +namespace CsharpFp3.Application; + +using CreateResultType = Result>, AppError>; + +public abstract record AppError +{ + public sealed record RepositoryCreationFailed(string Message) : AppError; + + public sealed record InnerAccountError(AccountError InnerError) : AppError; +} + +public static class AccountApplication +{ + public static CreateResultType CreateWithdrawMoney(Repository repo) => + CreateResultType.Ok( + (accountId, amount) => + repo.LoadAccount(accountId) + .Log("App load", "Account loaded") + .Log("App exec wdrwl", $"[App] Executing withdrawal of {amount:0.00}...") + .Bind(account => AccountDomain.Withdraw(account, new Money(amount))) + .Log( + "App wdrwl done", + a => $"Withdrawal applied. New balance: {a.Balance.Amount:0.00}" + ) + .Bind(repo.SaveAccount) + .Log("App acc svd", "[App] Account persisted.") + ); +} diff --git a/csharp-fp3/Domain/Account.cs b/csharp-fp3/Domain/Account.cs new file mode 100644 index 0000000..509ecec --- /dev/null +++ b/csharp-fp3/Domain/Account.cs @@ -0,0 +1,44 @@ +using CsharpFp3.Library; + +namespace CsharpFp3.Domain; + +public sealed record Account(Guid Id, Money Balance); + +public abstract record AccountError +{ + public sealed record OpeningBalanceMustBeNonNegative : AccountError; + + public sealed record AmountMustBePositive : AccountError; + + public sealed record InsufficientBalance(Money Balance, Money Amount) : AccountError; + + public sealed record AccountNotFound(Guid AccountId) : AccountError; +} + +public static class AccountDomain +{ + public static Result Open(Guid id, Money openingBalance) => + openingBalance.Amount < 0m + ? Result.Fail(new AccountError.OpeningBalanceMustBeNonNegative()) + : Result.Ok(new Account(id, openingBalance)); + + public static Result Withdraw(Account account, Money amount) => + (account.Balance.Amount, amount.Amount) switch + { + (_, <= 0m) => Result.Fail( + new AccountError.AmountMustBePositive() + ), + + var (balance, transaction) when balance < transaction => Result< + Account, + AccountError + >.Fail(new AccountError.InsufficientBalance(account.Balance, amount)), + + var (balance, transaction) => Result.Ok( + account with + { + Balance = new Money(balance - transaction), + } + ), + }; +} diff --git a/csharp-fp3/Domain/Money.cs b/csharp-fp3/Domain/Money.cs new file mode 100644 index 0000000..1715ba7 --- /dev/null +++ b/csharp-fp3/Domain/Money.cs @@ -0,0 +1,3 @@ +namespace CsharpFp3.Domain; + +public sealed record Money(decimal Amount); diff --git a/csharp-fp3/Infrastructure/InMemoryAccountRepository.cs b/csharp-fp3/Infrastructure/InMemoryAccountRepository.cs new file mode 100644 index 0000000..9c51559 --- /dev/null +++ b/csharp-fp3/Infrastructure/InMemoryAccountRepository.cs @@ -0,0 +1,44 @@ +using CsharpFp3.Domain; +using CsharpFp3.Library; +using static CsharpFp3.Library.Logging; + +namespace CsharpFp3.Infrastructure; + +public sealed record Repository( + Func> LoadAccount, + Func> SaveAccount +); + +public static class InMemoryAccountRepository +{ + public static Repository Create() + { + Dictionary store = new(); + + Result GetById(Guid id) => + store.TryGetValue(id, out var account) + ? LogReturn( + $"[Repo] Loaded account {id}", + Result.Ok(account) + ) + : LogReturn( + $"[Repo] Account {id} not found", + Result.Fail(new AccountError.AccountNotFound(id)) + ); + + Result Save(Account account) + { + store[account.Id] = account; + + // no failure modes here + return LogReturn( + $"[Repo] Saved account {account.Id} with balance {account.Balance.Amount:0.00}", + Result.Ok(account) + ); + } + + // What if something goes wrong? + // throw new Exception("Something went wrong"); + return new Repository(GetById, Save); + } +} diff --git a/csharp-fp3/Library/Logging.cs b/csharp-fp3/Library/Logging.cs new file mode 100644 index 0000000..6e11ba6 --- /dev/null +++ b/csharp-fp3/Library/Logging.cs @@ -0,0 +1,21 @@ +namespace CsharpFp3.Library; + +public static class Logging +{ + public static T LogReturn(string message, T r) + { + Console.WriteLine(message); + return r; + } + + public static Action Output(string src, string message) => + x => + { + Console.WriteLine($"[{src}] {message} | {x}"); + }; + + public static void OutputError(string src, T error) + { + Console.Error.WriteLine($"\e[1;31m[{src} ERROR]\e[0m {error}"); + } +} diff --git a/csharp-fp3/Library/Result.cs b/csharp-fp3/Library/Result.cs new file mode 100644 index 0000000..2dcac9c --- /dev/null +++ b/csharp-fp3/Library/Result.cs @@ -0,0 +1,126 @@ +namespace CsharpFp3.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 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.Output(src, msg)).TapError(m => Logging.OutputError(src, m)); + + public static Result Log( + this Result result, + string src, + Func renderText + ) => + Tap(result, x => Logging.Output(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-fp3/Program.cs b/csharp-fp3/Program.cs new file mode 100644 index 0000000..3b3448e --- /dev/null +++ b/csharp-fp3/Program.cs @@ -0,0 +1,41 @@ +using CsharpFp3.Application; +using CsharpFp3.Domain; +using CsharpFp3.Infrastructure; +using CsharpFp3.Library; + +Console.WriteLine("[csharp-fp3] Starting withdraw money demo..."); + +var accountId = Guid.NewGuid(); + +// try changing this to 250m to see an error being handled +var withdrawalAmount = 100m; + +Result + .Catch( + InMemoryAccountRepository.Create, + ex => new AppError.RepositoryCreationFailed(ex.Message) + ) + .Bind(repo => + AccountApplication + .CreateWithdrawMoney(repo) + .Bind(withdrawMoney => + AccountDomain + .Open(accountId, new Money(200m)) + .Log( + "csharp-fp3 seed", + account => + $"Seeding account {account.Id} with opening balance {account.Balance}" + ) + .Bind(repo.SaveAccount) + .Log( + "csharp-fp3 exec", + $"Executing withdrawal {withdrawalAmount:0.00} from account {accountId}" + ) + .Bind(account => + withdrawMoney(account.Id, withdrawalAmount) + ) + .Log("csharp-fp3 new balance", account => $"New balance is {account.Balance}") + .MapError(ae => (AppError)new AppError.InnerAccountError(ae)) + ) + ) + .Log("csharp-fp3 done", "Demo completed."); diff --git a/csharp-fp3/csharp-fp3.csproj b/csharp-fp3/csharp-fp3.csproj new file mode 100644 index 0000000..1c5ae39 --- /dev/null +++ b/csharp-fp3/csharp-fp3.csproj @@ -0,0 +1,9 @@ + + + Exe + net10.0 + CsharpFp3 + enable + enable + + diff --git a/csharp.sln b/csharp.sln index f7311d4..da5f00e 100644 --- a/csharp.sln +++ b/csharp.sln @@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "csharp-fp2", "csharp-fp2\cs EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "csharp-fp2.Tests", "csharp-fp2.Tests\csharp-fp2.Tests.csproj", "{4AFF063B-7125-4783-BE84-E1E9B4E2A462}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "csharp-fp3", "csharp-fp3\csharp-fp3.csproj", "{83AFEF87-AAC9-45F4-B803-1BCDF0443BF7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -115,6 +117,18 @@ Global {4AFF063B-7125-4783-BE84-E1E9B4E2A462}.Release|x64.Build.0 = Release|Any CPU {4AFF063B-7125-4783-BE84-E1E9B4E2A462}.Release|x86.ActiveCfg = Release|Any CPU {4AFF063B-7125-4783-BE84-E1E9B4E2A462}.Release|x86.Build.0 = Release|Any CPU + {83AFEF87-AAC9-45F4-B803-1BCDF0443BF7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {83AFEF87-AAC9-45F4-B803-1BCDF0443BF7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {83AFEF87-AAC9-45F4-B803-1BCDF0443BF7}.Debug|x64.ActiveCfg = Debug|Any CPU + {83AFEF87-AAC9-45F4-B803-1BCDF0443BF7}.Debug|x64.Build.0 = Debug|Any CPU + {83AFEF87-AAC9-45F4-B803-1BCDF0443BF7}.Debug|x86.ActiveCfg = Debug|Any CPU + {83AFEF87-AAC9-45F4-B803-1BCDF0443BF7}.Debug|x86.Build.0 = Debug|Any CPU + {83AFEF87-AAC9-45F4-B803-1BCDF0443BF7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {83AFEF87-AAC9-45F4-B803-1BCDF0443BF7}.Release|Any CPU.Build.0 = Release|Any CPU + {83AFEF87-AAC9-45F4-B803-1BCDF0443BF7}.Release|x64.ActiveCfg = Release|Any CPU + {83AFEF87-AAC9-45F4-B803-1BCDF0443BF7}.Release|x64.Build.0 = Release|Any CPU + {83AFEF87-AAC9-45F4-B803-1BCDF0443BF7}.Release|x86.ActiveCfg = Release|Any CPU + {83AFEF87-AAC9-45F4-B803-1BCDF0443BF7}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -127,5 +141,6 @@ Global {C9D46510-994A-4C43-BA8F-33CA9BED79D0} = {89C53051-77F9-4C1C-ABE5-6D54AB398471} {10A6FD3E-117A-4C9E-98E2-DAC31E7CE78A} = {89C53051-77F9-4C1C-ABE5-6D54AB398471} {4AFF063B-7125-4783-BE84-E1E9B4E2A462} = {89C53051-77F9-4C1C-ABE5-6D54AB398471} + {83AFEF87-AAC9-45F4-B803-1BCDF0443BF7} = {89C53051-77F9-4C1C-ABE5-6D54AB398471} EndGlobalSection EndGlobal