diff --git a/csharp-es/Domain/Account.cs b/csharp-es/Domain/Account.cs index 459fe93..6925e41 100644 --- a/csharp-es/Domain/Account.cs +++ b/csharp-es/Domain/Account.cs @@ -1,4 +1,5 @@ using CsharpEs.Library; +using static CsharpEs.Library.ResultModule; namespace CsharpEs.Domain; @@ -47,31 +48,27 @@ public static class AccountDecider (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() - ), + (null, AccountCommand.OpenAccount c) when c.OpeningBalance.Amount < 0m => Fail( + new AccountError.OpeningBalanceMustBeNonNegative() + ), // still a new account, now we can open it - (null, AccountCommand.OpenAccount c) => Result.Ok( + (null, AccountCommand.OpenAccount c) => 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() + (not null, AccountCommand.OpenAccount c) => Fail( + new AccountError.AccountOpenAlready() ), - (_, AccountCommand.WithdrawMoney c) => Result.Ok( + (null, _) => Fail(new AccountError.AccountNotFound()), + + (_, AccountCommand.WithdrawMoney c) => Ok( new AccountEvent.MoneyWithdrawn(c.AccountId, c.Amount) ), - (_, AccountCommand.DepositMoney c) => Result.Ok( + (_, AccountCommand.DepositMoney c) => Ok( new AccountEvent.MoneyDeposited(c.AccountId, c.Amount) ), @@ -86,10 +83,8 @@ public static class AccountDecider ) => (state, @event) switch { - (null, AccountEvent.AccountOpened e) => Result.Ok( - new AccountState(e.AccountId) - ), + (null, AccountEvent.AccountOpened e) => Ok(new AccountState(e.AccountId)), - _ => Result.Ok(state), + _ => Ok(state), }; } diff --git a/csharp-es/Infrastructure/AccountReadModel.cs b/csharp-es/Infrastructure/AccountReadModel.cs index e292d7f..823bd07 100644 --- a/csharp-es/Infrastructure/AccountReadModel.cs +++ b/csharp-es/Infrastructure/AccountReadModel.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using CsharpEs.Domain; using CsharpEs.Library; +using static CsharpEs.Library.ResultModule; namespace CsharpEs.Infrastructure; @@ -57,9 +58,7 @@ public static class AccountBalanceReadModelModule ); } else - return Result.Fail( - new ReadModelError.AccountDetailsNotFound() - ); + return Fail(new ReadModelError.AccountDetailsNotFound()); } break; @@ -78,9 +77,7 @@ public static class AccountBalanceReadModelModule ); } else - return Result.Fail( - new ReadModelError.AccountDetailsNotFound() - ); + return Fail(new ReadModelError.AccountDetailsNotFound()); } break; } @@ -88,15 +85,13 @@ public static class AccountBalanceReadModelModule // 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]); + return Ok(modelData[@event.AccountId]); } Result[], ReadModelError> Query() => - Result[], ReadModelError>.Ok(modelData.ToArray()); + Ok(modelData.ToArray()); - return Result.Ok( - new AccountBalanceReadModel(Project, Query) - ); + return Ok(new AccountBalanceReadModel(Project, Query)); } private static AccountFlags CalcAccountFlags(Money newBalance, AccountDetails oldDetails) diff --git a/csharp-es/Library/Result.cs b/csharp-es/Library/Result.cs index 57f55e7..123578a 100644 --- a/csharp-es/Library/Result.cs +++ b/csharp-es/Library/Result.cs @@ -1,138 +1,137 @@ namespace CsharpEs.Library; -public readonly record struct Result +// disable match exhaustion warnings - C# thinks we could have other +// derived instances of Result but there's no way to say +// "don't allow creation of further derived types" while leaving +// the type publicly visible. +#pragma warning disable CS8509 + +public abstract record Result { - private readonly T _value; + public static implicit operator Result(T value) => new ResultOk(value); - private readonly E _error; + public static implicit operator Result(E error) => new ResultFail(error); +} - public bool IsSuccess { get; } +public sealed record ResultOk(T Value) : Result; - public bool IsFailure => !IsSuccess; +public sealed record ResultFail(E Error) : Result; - public T Value => - IsSuccess - ? _value - : throw new InvalidOperationException("No value present for failed result."); +public static class ResultModule +{ + public static T Ok(T v) => v; - public E Error => - IsFailure - ? _error - : throw new InvalidOperationException("No error present for successful result."); + public static Result OkNone() + where T : class? => new ResultOk(null); - 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 E Fail(E e) => 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) + extension(Result result) { - if (result.IsSuccess) - action(result.Value); + public bool IsSuccess => result is ResultOk; + public bool IsFailure => !result.IsSuccess; - return result; + public Result Bind(Func> binder) => + result switch + { + ResultOk(var v) => binder(v), + ResultFail(var e) => new ResultFail(e), + }; + + public Result Map(Func mapper) => + result switch + { + ResultOk(var v) => new ResultOk(mapper(v)), + ResultFail(var e) => new ResultFail(e), + }; + + public Result MapError(Func mapper) => + result switch + { + ResultOk(var v) => new ResultOk(v), + ResultFail(var e) => new ResultFail(mapper(e)), + }; + + public Result Tap(Action action) + { + if (result is ResultOk(var v)) + action(v); + return result; + } + + public Result TapError(Action action) + { + if (result is ResultFail(var e)) + action(e); + return result; + } + + public Result Log(string src, string msg) => + Tap(result, Logging.OutputDelegate(src, msg)) + .TapError(m => Logging.OutputError(src, m)); + + public Result Log(string src, Func renderText) => + Tap(result, x => Logging.OutputDelegate(src, renderText(x))(x)) + .TapError(m => Logging.OutputError(src, m)); + + public Result Select(Func mapper) => Map(result, mapper); + + public Result SelectMany( + Func> binder, + Func projector + ) => Bind(result, x => binder(x).Map(y => projector(x, y))); + + public TResult Match(Func onSuccess, Func onFailure) => + result switch + { + ResultOk(var v) => onSuccess(v), + ResultFail(var e) => onFailure(e), + }; + + public void Switch(Action onSuccess, Action onFailure) + { + switch (result) + { + case ResultOk(var v): + onSuccess(v); + break; + case ResultFail(var e): + onFailure(e); + break; + } + } } - public static Result TapError(this Result result, Action action) + extension(Result) { - if (result.IsFailure) - action(result.Error); + public static Result Catch(Func f, Func exceptionMapper) + { + try + { + return new ResultOk(f()); + } + catch (Exception e) + { + return new ResultFail(exceptionMapper(e)); + } + } - return result; + public static Result Catch( + Func> f, + Func exceptionMapper + ) + { + try + { + return f(); + } + catch (Exception e) + { + return new ResultFail(exceptionMapper(e)); + } + } } - - 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 index c8e2436..220b6fd 100644 --- a/csharp-es/Program.cs +++ b/csharp-es/Program.cs @@ -2,6 +2,7 @@ using CsharpEs.Domain; using CsharpEs.Infrastructure; using CsharpEs.Library; +using static CsharpEs.Library.ResultModule; public abstract record DemoError { @@ -66,7 +67,7 @@ public class Program .Bind(readModel => demoCommands .Aggregate( - Result.Ok(null), + OkNone(), (stateResult, command) => stateResult .Bind(state => diff --git a/csharp-fp3.Tests/AccountTests.cs b/csharp-fp3.Tests/AccountTests.cs index dcc7632..0ac5f28 100644 --- a/csharp-fp3.Tests/AccountTests.cs +++ b/csharp-fp3.Tests/AccountTests.cs @@ -1,4 +1,5 @@ using CsharpFp3.Domain; +using CsharpFp3.Library; namespace CsharpFp3.Tests; @@ -11,46 +12,52 @@ public class AccountTests { var result = AccountDomain.Open(Guid.NewGuid(), new Money(-1m)); - Assert.IsType(result.Error); + Assert.True(result.Match(_ => false, e => e is AccountError.OpeningBalanceMustBeNonNegative)); } [Fact] public void Withdrawing_a_zero_amount_returns_AmountMustBePositive() { - var account = AccountDomain.Open(Guid.NewGuid(), new Money(100m)).Value; + var accountResult = AccountDomain.Open(Guid.NewGuid(), new Money(100m)); + var account = accountResult.Match(v => v, _ => throw new InvalidOperationException("Expected success")); var result = AccountDomain.Withdraw(account, new Money(0m)); - Assert.IsType(result.Error); + Assert.True(result.Match(_ => false, e => e is AccountError.AmountMustBePositive)); } [Fact] public void Withdrawing_a_negative_amount_returns_AmountMustBePositive() { - var account = AccountDomain.Open(Guid.NewGuid(), new Money(100m)).Value; + var accountResult = AccountDomain.Open(Guid.NewGuid(), new Money(100m)); + var account = accountResult.Match(v => v, _ => throw new InvalidOperationException("Expected success")); var result = AccountDomain.Withdraw(account, new Money(-10m)); - Assert.IsType(result.Error); + Assert.True(result.Match(_ => false, e => e is AccountError.AmountMustBePositive)); } [Fact] public void Withdrawing_more_than_the_balance_returns_InsufficientBalance() { - var account = AccountDomain.Open(Guid.NewGuid(), new Money(100m)).Value; + var accountResult = AccountDomain.Open(Guid.NewGuid(), new Money(100m)); + var account = accountResult.Match(v => v, _ => throw new InvalidOperationException("Expected success")); var result = AccountDomain.Withdraw(account, new Money(101m)); - Assert.IsType(result.Error); + Assert.True(result.Match(_ => false, e => e is AccountError.InsufficientBalance)); } [Fact] public void InsufficientBalance_error_reports_the_current_balance_and_attempted_amount() { - var account = AccountDomain.Open(Guid.NewGuid(), new Money(100m)).Value; + var accountResult = AccountDomain.Open(Guid.NewGuid(), new Money(100m)); + var account = accountResult.Match(v => v, _ => throw new InvalidOperationException("Expected success")); - var error = Assert.IsType( - AccountDomain.Withdraw(account, new Money(150m)).Error + var result = AccountDomain.Withdraw(account, new Money(150m)); + var error = result.Match( + _ => throw new InvalidOperationException("Expected failure"), + e => e is AccountError.InsufficientBalance ins ? ins : throw new InvalidOperationException("Expected InsufficientBalance") ); Assert.Equal(100m, error.Balance.Amount); @@ -60,13 +67,14 @@ public class AccountTests [Fact] public void Successive_withdrawals_are_each_applied_to_the_running_balance() { - var account = AccountDomain.Open(Guid.NewGuid(), new Money(300m)).Value; + var accountResult = AccountDomain.Open(Guid.NewGuid(), new Money(300m)); + var account = accountResult.Match(v => v, _ => throw new InvalidOperationException("Expected success")); var result1 = AccountDomain.Withdraw(account, new Money(100m)); - var updatedAccount1 = result1.Value; + var updatedAccount1 = result1.Match(v => v, _ => throw new InvalidOperationException("Expected success")); var result2 = AccountDomain.Withdraw(updatedAccount1, new Money(100m)); - var updatedAccount2 = result2.Value; + var updatedAccount2 = result2.Match(v => v, _ => throw new InvalidOperationException("Expected success")); Assert.Equal(100m, updatedAccount2.Balance.Amount); } diff --git a/csharp-fp3.Tests/ResultTests.cs b/csharp-fp3.Tests/ResultTests.cs index ed5d6d3..235f4a2 100644 --- a/csharp-fp3.Tests/ResultTests.cs +++ b/csharp-fp3.Tests/ResultTests.cs @@ -1,4 +1,5 @@ using CsharpFp3.Library; +using static CsharpFp3.Library.ResultModule; namespace CsharpFp3.Tests; @@ -10,7 +11,7 @@ public class ResultTests [Fact] public void Ok_is_success() { - var result = Result.Ok(42); + Result result = new ResultOk(42); Assert.True(result.IsSuccess); Assert.False(result.IsFailure); @@ -19,42 +20,26 @@ public class ResultTests [Fact] public void Fail_is_failure() { - var result = Result.Fail("oops"); + Result result = new ResultFail("oops"); Assert.True(result.IsFailure); Assert.False(result.IsSuccess); } [Fact] - public void Ok_exposes_value() + public void Ok_exposes_value_via_Match() { - var result = Result.Ok(42); + Result result = new ResultOk(42); - Assert.Equal(42, result.Value); + Assert.Equal(42, result.Match(v => v, _ => 0)); } [Fact] - public void Fail_exposes_error() + public void Fail_exposes_error_via_Match() { - var result = Result.Fail("oops"); + Result result = new ResultFail("oops"); - Assert.Equal("oops", result.Error); - } - - [Fact] - public void Accessing_Value_on_a_failed_result_throws() - { - var result = Result.Fail("oops"); - - Assert.Throws(() => result.Value); - } - - [Fact] - public void Accessing_Error_on_a_successful_result_throws() - { - var result = Result.Ok(42); - - Assert.Throws(() => result.Error); + Assert.Equal("oops", result.Match(_ => "", e => e)); } // --- Match --- @@ -62,7 +47,7 @@ public class ResultTests [Fact] public void Match_calls_onSuccess_for_Ok() { - var result = Result.Ok(10); + Result result = new ResultOk(10); var output = result.Match(v => $"value:{v}", e => $"error:{e}"); @@ -72,7 +57,7 @@ public class ResultTests [Fact] public void Match_calls_onFailure_for_Fail() { - var result = Result.Fail("bad"); + Result result = new ResultFail("bad"); var output = result.Match(v => $"value:{v}", e => $"error:{e}"); @@ -84,7 +69,7 @@ public class ResultTests [Fact] public void Switch_calls_onSuccess_for_Ok() { - var result = Result.Ok(7); + Result result = new ResultOk(7); var called = false; result.Switch(_ => called = true, _ => { }); @@ -95,7 +80,7 @@ public class ResultTests [Fact] public void Switch_calls_onFailure_for_Fail() { - var result = Result.Fail("err"); + Result result = new ResultFail("err"); var called = false; result.Switch(_ => { }, _ => called = true); @@ -111,7 +96,7 @@ public class ResultTests var result = Result.Catch(() => 99, ex => ex.Message); Assert.True(result.IsSuccess); - Assert.Equal(99, result.Value); + Assert.Equal(99, result.Match(v => v, _ => 0)); } [Fact] @@ -123,7 +108,7 @@ public class ResultTests ); Assert.True(result.IsFailure); - Assert.Equal("boom", result.Error); + Assert.Equal("boom", result.Match(_ => "", e => e)); } // --- Bind --- @@ -131,33 +116,34 @@ public class ResultTests [Fact] public void Bind_chains_to_the_next_result_on_success() { - var result = Result.Ok(5).Bind(v => Result.Ok($"got {v}")); + Result result = new ResultOk(5); + var chained = result.Bind(v => new ResultOk($"got {v}")); - Assert.Equal("got 5", result.Value); + Assert.Equal("got 5", chained.Match(v => v, _ => "")); } [Fact] public void Bind_short_circuits_on_failure_without_calling_the_binder() { var binderCalled = false; - var result = Result - .Fail("nope") - .Bind(v => - { - binderCalled = true; - return Result.Ok($"got {v}"); - }); + Result result = new ResultFail("nope"); + var chained = result.Bind(v => + { + binderCalled = true; + return new ResultOk($"got {v}"); + }); Assert.False(binderCalled); - Assert.Equal("nope", result.Error); + Assert.Equal("nope", chained.Match(_ => "", e => e)); } [Fact] public void Bind_propagates_the_inner_failure_when_the_binder_returns_Fail() { - var result = Result.Ok(5).Bind(_ => Result.Fail("inner fail")); + Result result = new ResultOk(5); + var chained = result.Bind(_ => new ResultFail("inner fail")); - Assert.Equal("inner fail", result.Error); + Assert.Equal("inner fail", chained.Match(_ => "", e => e)); } // --- Map --- @@ -165,23 +151,25 @@ public class ResultTests [Fact] public void Map_transforms_the_value_on_success() { - var result = Result.Ok(3).Map(v => v * 2); + Result result = new ResultOk(3); + var mapped = result.Map(v => v * 2); - Assert.Equal(6, result.Value); + Assert.Equal(6, mapped.Match(v => v, _ => 0)); } [Fact] public void Map_does_not_call_the_mapper_on_failure() { var mapperCalled = false; - var result = Result.Fail("err").Map(v => + Result result = new ResultFail("err"); + var mapped = result.Map(v => { mapperCalled = true; return v * 2; }); Assert.False(mapperCalled); - Assert.Equal("err", result.Error); + Assert.Equal("err", mapped.Match(_ => "", e => e)); } // --- Tap --- @@ -190,17 +178,19 @@ public class ResultTests public void Tap_calls_the_action_and_returns_the_original_result_on_success() { var seen = -1; - var result = Result.Ok(8).Tap(v => seen = v); + Result result = new ResultOk(8); + var tapped = result.Tap(v => seen = v); Assert.Equal(8, seen); - Assert.Equal(8, result.Value); + Assert.Equal(8, tapped.Match(v => v, _ => 0)); } [Fact] public void Tap_does_not_call_the_action_on_failure() { var called = false; - Result.Fail("err").Tap(_ => called = true); + Result result = new ResultFail("err"); + result.Tap(_ => called = true); Assert.False(called); } @@ -211,17 +201,19 @@ public class ResultTests public void TapError_calls_the_action_and_returns_the_original_result_on_failure() { var seen = ""; - var result = Result.Fail("bad").TapError(e => seen = e); + Result result = new ResultFail("bad"); + var tapped = result.TapError(e => seen = e); Assert.Equal("bad", seen); - Assert.Equal("bad", result.Error); + Assert.Equal("bad", tapped.Match(_ => "", e => e)); } [Fact] public void TapError_does_not_call_the_action_on_success() { var called = false; - Result.Ok(1).TapError(_ => called = true); + Result result = new ResultOk(1); + result.TapError(_ => called = true); Assert.False(called); } @@ -231,23 +223,25 @@ public class ResultTests [Fact] public void MapError_transforms_the_error_on_failure() { - var result = Result.Fail("oops").MapError(e => e.Length); + Result result = new ResultFail("oops"); + var mapped = result.MapError(e => e.Length); - Assert.Equal(4, result.Error); + Assert.Equal(4, mapped.Match(_ => 0, e => e)); } [Fact] public void MapError_does_not_call_the_mapper_on_success() { var mapperCalled = false; - var result = Result.Ok(1).MapError(e => + Result result = new ResultOk(1); + var mapped = result.MapError(e => { mapperCalled = true; return e.Length; }); Assert.False(mapperCalled); - Assert.Equal(1, result.Value); + Assert.Equal(1, mapped.Match(v => v, _ => 0)); } // --- Select / SelectMany (LINQ support) --- @@ -255,41 +249,48 @@ public class ResultTests [Fact] public void Select_is_equivalent_to_Map() { - var result = Result.Ok(4).Select(v => v + 1); + Result result = new ResultOk(4); + var selected = result.Select(v => v + 1); - Assert.Equal(5, result.Value); + Assert.Equal(5, selected.Match(v => v, _ => 0)); } [Fact] public void SelectMany_sequences_two_successful_results() { + Result aResult = new ResultOk(3); + Result bResult = new ResultOk(4); var result = - from a in Result.Ok(3) - from b in Result.Ok(4) + from a in aResult + from b in bResult select a + b; - Assert.Equal(7, result.Value); + Assert.Equal(7, result.Match(v => v, _ => 0)); } [Fact] public void SelectMany_short_circuits_when_the_first_result_fails() { + Result aResult = new ResultFail("first fail"); + Result bResult = new ResultOk(4); var result = - from a in Result.Fail("first fail") - from b in Result.Ok(4) + from a in aResult + from b in bResult select a + b; - Assert.Equal("first fail", result.Error); + Assert.Equal("first fail", result.Match(_ => "", e => e)); } [Fact] public void SelectMany_short_circuits_when_the_second_result_fails() { + Result aResult = new ResultOk(3); + Result bResult = new ResultFail("second fail"); var result = - from a in Result.Ok(3) - from b in Result.Fail("second fail") + from a in aResult + from b in bResult select a + b; - Assert.Equal("second fail", result.Error); + Assert.Equal("second fail", result.Match(_ => "", e => e)); } } diff --git a/csharp-fp3.Tests/WithdrawMoneyTests.cs b/csharp-fp3.Tests/WithdrawMoneyTests.cs index 4e72cfb..b33a1e7 100644 --- a/csharp-fp3.Tests/WithdrawMoneyTests.cs +++ b/csharp-fp3.Tests/WithdrawMoneyTests.cs @@ -2,6 +2,7 @@ using CsharpFp3.Application; using CsharpFp3.Domain; using CsharpFp3.Infrastructure; using CsharpFp3.Library; +using static CsharpFp3.Library.ResultModule; namespace CsharpFp3.Tests; @@ -15,7 +16,8 @@ public class WithdrawMoneyTests ) BuildHandler() { var repo = InMemoryAccountRepository.Create(); - var withdraw = AccountApplication.CreateWithdrawMoney(repo).Value; + var result = AccountApplication.CreateWithdrawMoney(repo); + var withdraw = result.Match(v => v, _ => throw new InvalidOperationException("Expected success")); return (withdraw, repo); } @@ -24,11 +26,12 @@ public class WithdrawMoneyTests { var (withdraw, repo) = BuildHandler(); var accountId = Guid.NewGuid(); - repo.SaveAccount(AccountDomain.Open(accountId, new Money(200m)).Value); + var accountResult = AccountDomain.Open(accountId, new Money(200m)); + repo.SaveAccount(accountResult.Match(v => v, _ => throw new InvalidOperationException("Expected success"))); withdraw(accountId, 75m); - var account = repo.LoadAccount(accountId).Value; + var account = repo.LoadAccount(accountId).Match(v => v, _ => throw new InvalidOperationException("Expected success")); Assert.Equal(125m, account.Balance.Amount); } @@ -37,11 +40,12 @@ public class WithdrawMoneyTests { var (withdraw, repo) = BuildHandler(); var accountId = Guid.NewGuid(); - repo.SaveAccount(AccountDomain.Open(accountId, new Money(100m)).Value); + var accountResult = AccountDomain.Open(accountId, new Money(100m)); + repo.SaveAccount(accountResult.Match(v => v, _ => throw new InvalidOperationException("Expected success"))); withdraw(accountId, 100m); - var account = repo.LoadAccount(accountId).Value; + var account = repo.LoadAccount(accountId).Match(v => v, _ => throw new InvalidOperationException("Expected success")); Assert.Equal(0m, account.Balance.Amount); } @@ -52,7 +56,7 @@ public class WithdrawMoneyTests var result = withdraw(Guid.NewGuid(), 50m); - Assert.IsType(result.Error); + Assert.True(result.Match(_ => false, e => e is AccountError.AccountNotFound)); } [Fact] @@ -60,11 +64,12 @@ public class WithdrawMoneyTests { var (withdraw, repo) = BuildHandler(); var accountId = Guid.NewGuid(); - repo.SaveAccount(AccountDomain.Open(accountId, new Money(50m)).Value); + var accountResult = AccountDomain.Open(accountId, new Money(50m)); + repo.SaveAccount(accountResult.Match(v => v, _ => throw new InvalidOperationException("Expected success"))); var result = withdraw(accountId, 100m); - Assert.IsType(result.Error); + Assert.True(result.Match(_ => false, e => e is AccountError.InsufficientBalance)); } [Fact] @@ -73,8 +78,9 @@ public class WithdrawMoneyTests var (withdraw, _) = BuildHandler(); var accountId = Guid.NewGuid(); - var error = Assert.IsType( - withdraw(accountId, 50m).Error + var error = withdraw(accountId, 50m).Match( + _ => throw new InvalidOperationException("Expected failure"), + e => e is AccountError.AccountNotFound notFound ? notFound : throw new InvalidOperationException("Expected AccountNotFound") ); Assert.Equal(accountId, error.AccountId); @@ -85,10 +91,12 @@ public class WithdrawMoneyTests { var (withdraw, repo) = BuildHandler(); var accountId = Guid.NewGuid(); - repo.SaveAccount(AccountDomain.Open(accountId, new Money(50m)).Value); + var accountResult = AccountDomain.Open(accountId, new Money(50m)); + repo.SaveAccount(accountResult.Match(v => v, _ => throw new InvalidOperationException("Expected success"))); - var error = Assert.IsType( - withdraw(accountId, 120m).Error + var error = withdraw(accountId, 120m).Match( + _ => throw new InvalidOperationException("Expected failure"), + e => e is AccountError.InsufficientBalance ins ? ins : throw new InvalidOperationException("Expected InsufficientBalance") ); Assert.Equal(50m, error.Balance.Amount); @@ -100,11 +108,12 @@ public class WithdrawMoneyTests { var (withdraw, repo) = BuildHandler(); var accountId = Guid.NewGuid(); - repo.SaveAccount(AccountDomain.Open(accountId, new Money(50m)).Value); + var accountResult = AccountDomain.Open(accountId, new Money(50m)); + repo.SaveAccount(accountResult.Match(v => v, _ => throw new InvalidOperationException("Expected success"))); withdraw(accountId, 999m); - var account = repo.LoadAccount(accountId).Value; + var account = repo.LoadAccount(accountId).Match(v => v, _ => throw new InvalidOperationException("Expected success")); Assert.Equal(50m, account.Balance.Amount); } } diff --git a/csharp-fp3/Application/AccountApplication.cs b/csharp-fp3/Application/AccountApplication.cs index 7dd66a0..8a64590 100644 --- a/csharp-fp3/Application/AccountApplication.cs +++ b/csharp-fp3/Application/AccountApplication.cs @@ -1,6 +1,7 @@ using CsharpFp3.Domain; using CsharpFp3.Infrastructure; using CsharpFp3.Library; +using static CsharpFp3.Library.ResultModule; namespace CsharpFp3.Application; @@ -16,8 +17,9 @@ public abstract record AppError public static class AccountApplication { public static CreateResultType CreateWithdrawMoney(Repository repo) => - CreateResultType.Ok( - (accountId, amount) => + Ok( + // types needed in this lambda, otherwise c# doesn't love us + (Guid accountId, decimal amount) => repo.LoadAccount(accountId) .Log("App load", "Account loaded") .Log("App exec wdrwl", $"[App] Executing withdrawal of {amount:0.00}...") diff --git a/csharp-fp3/Domain/Account.cs b/csharp-fp3/Domain/Account.cs index 509ecec..e6709c4 100644 --- a/csharp-fp3/Domain/Account.cs +++ b/csharp-fp3/Domain/Account.cs @@ -1,4 +1,5 @@ using CsharpFp3.Library; +using static CsharpFp3.Library.ResultModule; namespace CsharpFp3.Domain; @@ -19,22 +20,19 @@ 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)); + ? Fail(new AccountError.OpeningBalanceMustBeNonNegative()) + : 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() + (_, <= 0m) => Fail(new AccountError.AmountMustBePositive()), + + var (balance, transaction) when balance < transaction => Fail( + new AccountError.InsufficientBalance(account.Balance, amount) ), - var (balance, transaction) when balance < transaction => Result< - Account, - AccountError - >.Fail(new AccountError.InsufficientBalance(account.Balance, amount)), - - var (balance, transaction) => Result.Ok( + var (balance, transaction) => Ok( account with { Balance = new Money(balance - transaction), diff --git a/csharp-fp3/Infrastructure/InMemoryAccountRepository.cs b/csharp-fp3/Infrastructure/InMemoryAccountRepository.cs index 9c51559..d2f7d8e 100644 --- a/csharp-fp3/Infrastructure/InMemoryAccountRepository.cs +++ b/csharp-fp3/Infrastructure/InMemoryAccountRepository.cs @@ -1,6 +1,7 @@ using CsharpFp3.Domain; using CsharpFp3.Library; using static CsharpFp3.Library.Logging; +using static CsharpFp3.Library.ResultModule; namespace CsharpFp3.Infrastructure; @@ -17,13 +18,10 @@ public static class InMemoryAccountRepository Result GetById(Guid id) => store.TryGetValue(id, out var account) - ? LogReturn( - $"[Repo] Loaded account {id}", - Result.Ok(account) - ) + ? LogReturn($"[Repo] Loaded account {id}", Ok(account)) : LogReturn( $"[Repo] Account {id} not found", - Result.Fail(new AccountError.AccountNotFound(id)) + Fail(new AccountError.AccountNotFound(id)) ); Result Save(Account account) @@ -33,7 +31,7 @@ public static class InMemoryAccountRepository // no failure modes here return LogReturn( $"[Repo] Saved account {account.Id} with balance {account.Balance.Amount:0.00}", - Result.Ok(account) + Ok(account) ); } diff --git a/csharp-fp3/Library/Logging.cs b/csharp-fp3/Library/Logging.cs index 6e11ba6..1a5a75e 100644 --- a/csharp-fp3/Library/Logging.cs +++ b/csharp-fp3/Library/Logging.cs @@ -8,14 +8,38 @@ public static class Logging return r; } - public static Action Output(string src, string message) => + 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) => + Indent(ObjectDumper.Dump(o, new DumpOptions() { DumpStyle = DumpStyle.CSharp })); + + 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 => { - Console.WriteLine($"[{src}] {message} | {x}"); + Output(src, message, x); }; public static void OutputError(string src, T error) { - Console.Error.WriteLine($"\e[1;31m[{src} ERROR]\e[0m {error}"); + Console.Error.WriteLine($"\e[1;31m[{src} ERROR]\e[0m {Format(error)}"); } } diff --git a/csharp-fp3/Library/Result.cs b/csharp-fp3/Library/Result.cs index 2dcac9c..d690b5f 100644 --- a/csharp-fp3/Library/Result.cs +++ b/csharp-fp3/Library/Result.cs @@ -1,126 +1,137 @@ namespace CsharpFp3.Library; -public readonly record struct Result +// disable match exhaustion warnings - C# thinks we could have other +// derived instances of Result but there's no way to say +// "don't allow creation of further derived types" while leaving +// the type publicly visible. +#pragma warning disable CS8509 + +public abstract record Result { - private readonly T _value; + public static implicit operator Result(T value) => new ResultOk(value); - private readonly E _error; + public static implicit operator Result(E error) => new ResultFail(error); +} - public bool IsSuccess { get; } +public sealed record ResultOk(T Value) : Result; - public bool IsFailure => !IsSuccess; +public sealed record ResultFail(E Error) : Result; - public T Value => - IsSuccess - ? _value - : throw new InvalidOperationException("No value present for failed result."); +public static class ResultModule +{ + public static T Ok(T v) => v; - public E Error => - IsFailure - ? _error - : throw new InvalidOperationException("No error present for successful result."); + public static Result OkNone() + where T : class? => new ResultOk(null); - 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 E Fail(E e) => 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) + extension(Result result) { - if (result.IsSuccess) - action(result.Value); + public bool IsSuccess => result is ResultOk; + public bool IsFailure => !result.IsSuccess; - return result; + public Result Bind(Func> binder) => + result switch + { + ResultOk(var v) => binder(v), + ResultFail(var e) => new ResultFail(e), + }; + + public Result Map(Func mapper) => + result switch + { + ResultOk(var v) => new ResultOk(mapper(v)), + ResultFail(var e) => new ResultFail(e), + }; + + public Result MapError(Func mapper) => + result switch + { + ResultOk(var v) => new ResultOk(v), + ResultFail(var e) => new ResultFail(mapper(e)), + }; + + public Result Tap(Action action) + { + if (result is ResultOk(var v)) + action(v); + return result; + } + + public Result TapError(Action action) + { + if (result is ResultFail(var e)) + action(e); + return result; + } + + public Result Log(string src, string msg) => + Tap(result, Logging.OutputDelegate(src, msg)) + .TapError(m => Logging.OutputError(src, m)); + + public Result Log(string src, Func renderText) => + Tap(result, x => Logging.OutputDelegate(src, renderText(x))(x)) + .TapError(m => Logging.OutputError(src, m)); + + public Result Select(Func mapper) => Map(result, mapper); + + public Result SelectMany( + Func> binder, + Func projector + ) => Bind(result, x => binder(x).Map(y => projector(x, y))); + + public TResult Match(Func onSuccess, Func onFailure) => + result switch + { + ResultOk(var v) => onSuccess(v), + ResultFail(var e) => onFailure(e), + }; + + public void Switch(Action onSuccess, Action onFailure) + { + switch (result) + { + case ResultOk(var v): + onSuccess(v); + break; + case ResultFail(var e): + onFailure(e); + break; + } + } } - public static Result TapError(this Result result, Action action) + extension(Result) { - if (result.IsFailure) - action(result.Error); + public static Result Catch(Func f, Func exceptionMapper) + { + try + { + return new ResultOk(f()); + } + catch (Exception e) + { + return new ResultFail(exceptionMapper(e)); + } + } - return result; + public static Result Catch( + Func> f, + Func exceptionMapper + ) + { + try + { + return f(); + } + catch (Exception e) + { + return new ResultFail(exceptionMapper(e)); + } + } } - - 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 index 3b3448e..684f647 100644 --- a/csharp-fp3/Program.cs +++ b/csharp-fp3/Program.cs @@ -31,9 +31,7 @@ Result "csharp-fp3 exec", $"Executing withdrawal {withdrawalAmount:0.00} from account {accountId}" ) - .Bind(account => - withdrawMoney(account.Id, withdrawalAmount) - ) + .Bind(account => withdrawMoney(account.Id, withdrawalAmount)) .Log("csharp-fp3 new balance", account => $"New balance is {account.Balance}") .MapError(ae => (AppError)new AppError.InnerAccountError(ae)) ) diff --git a/csharp-fp3/csharp-fp3.csproj b/csharp-fp3/csharp-fp3.csproj index 1c5ae39..67b4e11 100644 --- a/csharp-fp3/csharp-fp3.csproj +++ b/csharp-fp3/csharp-fp3.csproj @@ -6,4 +6,7 @@ enable enable + + +