improve result structure and handling

This commit is contained in:
Oli Sturm
2026-05-04 19:39:49 +01:00
parent 0d02927ba6
commit 73ed4c7b9c
14 changed files with 413 additions and 371 deletions
+4 -2
View File
@@ -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}...")
+8 -10
View File
@@ -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<Account, AccountError> Open(Guid id, Money openingBalance) =>
openingBalance.Amount < 0m
? Result<Account, AccountError>.Fail(new AccountError.OpeningBalanceMustBeNonNegative())
: Result<Account, AccountError>.Ok(new Account(id, openingBalance));
? Fail(new AccountError.OpeningBalanceMustBeNonNegative())
: Ok(new Account(id, openingBalance));
public static Result<Account, AccountError> Withdraw(Account account, Money amount) =>
(account.Balance.Amount, amount.Amount) switch
{
(_, <= 0m) => Result<Account, AccountError>.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<Account, AccountError>.Ok(
var (balance, transaction) => Ok(
account with
{
Balance = new Money(balance - transaction),
@@ -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<Account, AccountError> GetById(Guid id) =>
store.TryGetValue(id, out var account)
? LogReturn(
$"[Repo] Loaded account {id}",
Result<Account, AccountError>.Ok(account)
)
? LogReturn($"[Repo] Loaded account {id}", Ok(account))
: LogReturn(
$"[Repo] Account {id} not found",
Result<Account, AccountError>.Fail(new AccountError.AccountNotFound(id))
Fail(new AccountError.AccountNotFound(id))
);
Result<Account, AccountError> 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<Account, AccountError>.Ok(account)
Ok(account)
);
}
+27 -3
View File
@@ -8,14 +8,38 @@ public static class Logging
return r;
}
public static Action<T> Output<T>(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<T> OutputDelegate<T>(string src, string message) =>
x =>
{
Console.WriteLine($"[{src}] {message} | {x}");
Output(src, message, x);
};
public static void OutputError<T>(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)}");
}
}
+116 -105
View File
@@ -1,126 +1,137 @@
namespace CsharpFp3.Library;
public readonly record struct Result<T, E>
// disable match exhaustion warnings - C# thinks we could have other
// derived instances of Result<T,E> 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<T, E>
{
private readonly T _value;
public static implicit operator Result<T, E>(T value) => new ResultOk<T, E>(value);
private readonly E _error;
public static implicit operator Result<T, E>(E error) => new ResultFail<T, E>(error);
}
public bool IsSuccess { get; }
public sealed record ResultOk<T, E>(T Value) : Result<T, E>;
public bool IsFailure => !IsSuccess;
public sealed record ResultFail<T, E>(E Error) : Result<T, E>;
public T Value =>
IsSuccess
? _value
: throw new InvalidOperationException("No value present for failed result.");
public static class ResultModule
{
public static T Ok<T>(T v) => v;
public E Error =>
IsFailure
? _error
: throw new InvalidOperationException("No error present for successful result.");
public static Result<T?, E> OkNone<T, E>()
where T : class? => new ResultOk<T?, E>(null);
private Result(T value)
{
_value = value;
_error = default!;
IsSuccess = true;
}
private Result(E error)
{
_value = default!;
_error = error;
IsSuccess = false;
}
public static Result<T, E> Ok(T value) => new(value);
public static Result<T, E> Fail(E error) => new(error);
public TResult Match<TResult>(Func<T, TResult> onSuccess, Func<E, TResult> onFailure) =>
IsSuccess ? onSuccess(_value) : onFailure(_error);
public void Switch(Action<T> onSuccess, Action<E> onFailure)
{
if (IsSuccess)
onSuccess(_value);
else
onFailure(_error);
}
public static Result<T, E> Catch(Func<T> f, Func<Exception, E> exceptionMapper)
{
try
{
var result = f();
return Result<T, E>.Ok(result);
}
catch (Exception e)
{
return Result<T, E>.Fail(exceptionMapper(e));
}
}
public static E Fail<E>(E e) => e;
}
public static class ResultExtensions
{
public static Result<TOut, E> Bind<TIn, TOut, E>(
this Result<TIn, E> result,
Func<TIn, Result<TOut, E>> binder
) => result.IsSuccess ? binder(result.Value) : Result<TOut, E>.Fail(result.Error);
public static Result<TOut, E> Map<TIn, TOut, E>(
this Result<TIn, E> result,
Func<TIn, TOut> mapper
) =>
result.IsSuccess
? Result<TOut, E>.Ok(mapper(result.Value))
: Result<TOut, E>.Fail(result.Error);
public static Result<T, E> Tap<T, E>(this Result<T, E> result, Action<T> action)
extension<TIn, E>(Result<TIn, E> result)
{
if (result.IsSuccess)
action(result.Value);
public bool IsSuccess => result is ResultOk<TIn, E>;
public bool IsFailure => !result.IsSuccess;
return result;
public Result<TOut, E> Bind<TOut>(Func<TIn, Result<TOut, E>> binder) =>
result switch
{
ResultOk<TIn, E>(var v) => binder(v),
ResultFail<TIn, E>(var e) => new ResultFail<TOut, E>(e),
};
public Result<TOut, E> Map<TOut>(Func<TIn, TOut> mapper) =>
result switch
{
ResultOk<TIn, E>(var v) => new ResultOk<TOut, E>(mapper(v)),
ResultFail<TIn, E>(var e) => new ResultFail<TOut, E>(e),
};
public Result<TIn, EOut> MapError<EOut>(Func<E, EOut> mapper) =>
result switch
{
ResultOk<TIn, E>(var v) => new ResultOk<TIn, EOut>(v),
ResultFail<TIn, E>(var e) => new ResultFail<TIn, EOut>(mapper(e)),
};
public Result<TIn, E> Tap(Action<TIn> action)
{
if (result is ResultOk<TIn, E>(var v))
action(v);
return result;
}
public Result<TIn, E> TapError(Action<E> action)
{
if (result is ResultFail<TIn, E>(var e))
action(e);
return result;
}
public Result<TIn, E> Log(string src, string msg) =>
Tap(result, Logging.OutputDelegate<TIn>(src, msg))
.TapError(m => Logging.OutputError(src, m));
public Result<TIn, E> Log(string src, Func<TIn, string> renderText) =>
Tap(result, x => Logging.OutputDelegate<TIn>(src, renderText(x))(x))
.TapError(m => Logging.OutputError(src, m));
public Result<TOut, E> Select<TOut>(Func<TIn, TOut> mapper) => Map(result, mapper);
public Result<TOut, E> SelectMany<TIntermediate, TOut>(
Func<TIn, Result<TIntermediate, E>> binder,
Func<TIn, TIntermediate, TOut> projector
) => Bind(result, x => binder(x).Map(y => projector(x, y)));
public TResult Match<TResult>(Func<TIn, TResult> onSuccess, Func<E, TResult> onFailure) =>
result switch
{
ResultOk<TIn, E>(var v) => onSuccess(v),
ResultFail<TIn, E>(var e) => onFailure(e),
};
public void Switch(Action<TIn> onSuccess, Action<E> onFailure)
{
switch (result)
{
case ResultOk<TIn, E>(var v):
onSuccess(v);
break;
case ResultFail<TIn, E>(var e):
onFailure(e);
break;
}
}
}
public static Result<T, E> TapError<T, E>(this Result<T, E> result, Action<E> action)
extension<TIn, E>(Result<TIn, E>)
{
if (result.IsFailure)
action(result.Error);
public static Result<TIn, E> Catch(Func<TIn> f, Func<Exception, E> exceptionMapper)
{
try
{
return new ResultOk<TIn, E>(f());
}
catch (Exception e)
{
return new ResultFail<TIn, E>(exceptionMapper(e));
}
}
return result;
public static Result<TIn, E> Catch(
Func<Result<TIn, E>> f,
Func<Exception, E> exceptionMapper
)
{
try
{
return f();
}
catch (Exception e)
{
return new ResultFail<TIn, E>(exceptionMapper(e));
}
}
}
public static Result<T, EOut> MapError<T, EIn, EOut>(
this Result<T, EIn> result,
Func<EIn, EOut> map
) =>
result.IsSuccess
? Result<T, EOut>.Ok(result.Value)
: Result<T, EOut>.Fail(map(result.Error));
public static Result<T, E> Log<T, E>(this Result<T, E> result, string src, string msg) =>
Tap(result, Logging.Output<T>(src, msg)).TapError(m => Logging.OutputError(src, m));
public static Result<T, E> Log<T, E>(
this Result<T, E> result,
string src,
Func<T, string> renderText
) =>
Tap(result, x => Logging.Output<T>(src, renderText(x))(x))
.TapError(m => Logging.OutputError(src, m));
public static Result<TOut, E> Select<TIn, TOut, E>(
this Result<TIn, E> result,
Func<TIn, TOut> mapper
) => result.Map(mapper);
public static Result<TOut, E> SelectMany<TIn, TIntermediate, TOut, E>(
this Result<TIn, E> result,
Func<TIn, Result<TIntermediate, E>> binder,
Func<TIn, TIntermediate, TOut> projector
) => result.Bind(x => binder(x).Map(y => projector(x, y)));
}
+1 -3
View File
@@ -31,9 +31,7 @@ Result<Repository, AppError>
"csharp-fp3 exec",
$"Executing withdrawal {withdrawalAmount:0.00} from account {accountId}"
)
.Bind<Account, Account, AccountError>(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))
)
+3
View File
@@ -6,4 +6,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ObjectDumper.NET" Version="4.3.2" />
</ItemGroup>
</Project>