SimpleResults

A lightweight implementation of the Result pattern for error handling without exceptions
git clone git://git.hanetzok.net/SimpleResults
Log | Files | Refs

commit 3743d650067816b5cd38967be75a4ddb7cd3b035
parent ef4b33443ac48a5eba331a495ec99e2e848de174
Author: Markus Hanetzok <markus@hanetzok.net>
Date:   Fri, 24 Oct 2025 21:12:44 +0200

Refactor result pattern and add tests

- Made `IsSuccess`, `Value`, and `ErrorDetail` in `TypedResult` and `Result` private for better encapsulation.
- Added `TryGetValue` and `TryGetError` methods for safe access to result values and errors.
- Updated `ErrorDetail` properties to be immutable (`init`).
- Introduced unit tests for `Result` and `TypedResult` to ensure correctness.
- Added a new test project to the solution (`Tests`).

Diffstat:
MSimpleResults.sln | 6++++++
MSimpleResults/Errors/ErrorDetail.cs | 6+++---
MSimpleResults/Results/Result.cs | 15+++++++++++----
MSimpleResults/Results/TypedResult.cs | 54++++++++++++++++++++++++++++++++++--------------------
ATests/Results/ResultTests.cs | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Results/TypedResultTests.cs | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Tests.csproj | 26++++++++++++++++++++++++++
7 files changed, 321 insertions(+), 27 deletions(-)

diff --git a/SimpleResults.sln b/SimpleResults.sln @@ -2,6 +2,8 @@ Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleResults", "SimpleResults\SimpleResults.csproj", "{070FDC4D-2370-45C3-BBFB-EE6067972EA9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{6DE7C68A-9FAD-4C70-BC7C-D4C3EB79E65F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -12,5 +14,9 @@ Global {070FDC4D-2370-45C3-BBFB-EE6067972EA9}.Debug|Any CPU.Build.0 = Debug|Any CPU {070FDC4D-2370-45C3-BBFB-EE6067972EA9}.Release|Any CPU.ActiveCfg = Release|Any CPU {070FDC4D-2370-45C3-BBFB-EE6067972EA9}.Release|Any CPU.Build.0 = Release|Any CPU + {6DE7C68A-9FAD-4C70-BC7C-D4C3EB79E65F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6DE7C68A-9FAD-4C70-BC7C-D4C3EB79E65F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6DE7C68A-9FAD-4C70-BC7C-D4C3EB79E65F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6DE7C68A-9FAD-4C70-BC7C-D4C3EB79E65F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/SimpleResults/Errors/ErrorDetail.cs b/SimpleResults/Errors/ErrorDetail.cs @@ -43,7 +43,7 @@ public class ErrorDetail( /// An integer representing a specific error type. The meaning of specific codes /// is defined by the application using this class. /// </value> - public int ErrorCode { get; set; } = errorCode; + public int ErrorCode { get; init; } = errorCode; /// <summary> /// Gets or sets the human-readable error message describing what went wrong. @@ -51,7 +51,7 @@ public class ErrorDetail( /// <value> /// A string containing a descriptive error message suitable for logging or displaying to users. /// </value> - public string Message { get; set; } = message; + public string Message { get; init; } = message; /// <summary> /// Gets or sets the exception that caused the error, if applicable. @@ -60,5 +60,5 @@ public class ErrorDetail( /// An <see cref="Exception"/> object if the error was caused by an exception; /// otherwise, <c>null</c>. /// </value> - public Exception? Exception { get; set; } = exception; + public Exception? Exception { get; init; } = exception; } \ No newline at end of file diff --git a/SimpleResults/Results/Result.cs b/SimpleResults/Results/Result.cs @@ -16,6 +16,7 @@ */ using SimpleResults.Errors; +// ReSharper disable UnusedMember.Global // ReSharper disable UnusedAutoPropertyAccessor.Global namespace SimpleResults.Results; @@ -36,7 +37,7 @@ public class Result /// <value> /// <c>true</c> if the operation succeeded; otherwise, <c>false</c>. /// </value> - public bool IsSuccess { get; set; } + private bool IsSuccess { get; } /// <summary> /// Gets a value indicating whether the operation failed. @@ -46,7 +47,7 @@ public class Result /// This is the inverse of <see cref="IsSuccess"/>. /// </value> public bool IsFailure => !IsSuccess; - + /// <summary> /// Gets or sets the error details when the operation fails. /// </summary> @@ -54,7 +55,7 @@ public class Result /// An <see cref="ErrorDetail"/> object containing information about the failure, /// or <c>null</c> if the operation succeeded. /// </value> - public ErrorDetail? ErrorDetail { get; set; } + private ErrorDetail? Error { get; } /// <summary> /// Initializes a new instance of the <see cref="Result"/> class representing a successful operation. @@ -79,7 +80,7 @@ public class Result protected Result(ErrorDetail errorDetail) { IsSuccess = false; - ErrorDetail = errorDetail; + Error = errorDetail; } /// <summary> @@ -143,4 +144,10 @@ public class Result /// </code> /// </example> public static Result Failure(int code, string msg, Exception? e) => new(new ErrorDetail(code, msg, e)); + + public ErrorDetail TryGetError() + { + if (IsSuccess) throw new InvalidOperationException("Can't get error: Result state is success!"); + return Error ?? throw new NullReferenceException("TypedResult.Error is null"); + } } \ No newline at end of file diff --git a/SimpleResults/Results/TypedResult.cs b/SimpleResults/Results/TypedResult.cs @@ -16,7 +16,10 @@ */ using SimpleResults.Errors; + // ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable UnusedType.Global +// ReSharper disable UnusedMember.Global namespace SimpleResults.Results; @@ -28,11 +31,11 @@ namespace SimpleResults.Results; /// Must have a parameterless constructor.</typeparam> /// <remarks> /// This class implements the Result pattern with a typed return value, providing a type-safe way -/// to handle operation outcomes without throwing exceptions. It encapsulates success/failure state, +/// to handle operation outcomes without throwing exceptions. It encapsulates a success/failure state, /// a return value (on success), and error details (on failure). /// Use <see cref="Result"/> when the operation does not need to return a value. /// </remarks> -public class TypedResult<T> where T : new() +public class TypedResult<T> { /// <summary> /// Gets or sets a value indicating whether the operation completed successfully. @@ -40,8 +43,8 @@ public class TypedResult<T> where T : new() /// <value> /// <c>true</c> if the operation succeeded; otherwise, <c>false</c>. /// </value> - public bool IsSuccess { get; set; } - + private bool IsSuccess { get; } + /// <summary> /// Gets a value indicating whether the operation failed. /// </summary> @@ -50,7 +53,7 @@ public class TypedResult<T> where T : new() /// This is the inverse of <see cref="IsSuccess"/>. /// </value> public bool IsFailure => !IsSuccess; - + /// <summary> /// Gets or sets the value returned by the successful operation. /// </summary> @@ -61,16 +64,16 @@ public class TypedResult<T> where T : new() /// <remarks> /// Always check <see cref="IsSuccess"/> before accessing this property to ensure the operation succeeded. /// </remarks> - public T Value { get; set; } = new(); - + private readonly T? _value; + /// <summary> /// Gets or sets the error details when the operation fails. /// </summary> /// <value> - /// An <see cref="ErrorDetail"/> object containing information about the failure, + /// An <see cref="_error"/> object containing information about the failure, /// or <c>null</c> if the operation succeeded. /// </value> - public ErrorDetail? ErrorDetail { get; set; } + private readonly ErrorDetail? _error; /// <summary> /// Initializes a new instance of the <see cref="TypedResult{T}"/> class representing @@ -84,22 +87,21 @@ public class TypedResult<T> where T : new() protected TypedResult(T value) { IsSuccess = true; - Value = value; - + _value = value; } - + /// <summary> /// Initializes a new instance of the <see cref="TypedResult{T}"/> class representing a failed operation. /// </summary> - /// <param name="errorDetail">The details of the error that caused the operation to fail.</param> + /// <param name="error">The details of the error that caused the operation to fail.</param> /// <remarks> /// This constructor is protected and should not be called directly. /// Use the Failure factory methods instead. /// </remarks> - protected TypedResult(ErrorDetail errorDetail) + protected TypedResult(ErrorDetail error) { IsSuccess = false; - ErrorDetail = errorDetail; + _error = error; } /// <summary> @@ -107,7 +109,7 @@ public class TypedResult<T> where T : new() /// </summary> /// <param name="value">The value produced by the successful operation.</param> /// <returns>A <see cref="TypedResult{T}"/> instance with <see cref="IsSuccess"/> set to <c>true</c> - /// and <see cref="Value"/> set to the provided value.</returns> + /// and <see cref="_value"/> set to the provided value.</returns> /// <example> /// <code> /// public TypedResult&lt;User&gt; GetUser(int id) @@ -118,13 +120,13 @@ public class TypedResult<T> where T : new() /// </code> /// </example> public static TypedResult<T> Success(T value) => new(value); - + /// <summary> /// Creates a new <see cref="TypedResult{T}"/> representing a failed operation with the specified error details. /// </summary> /// <param name="error">The error details describing why the operation failed.</param> /// <returns>A <see cref="TypedResult{T}"/> instance with <see cref="IsSuccess"/> set to <c>false</c> - /// and <see cref="ErrorDetail"/> populated with the provided error information.</returns> + /// and <see cref="_error"/> populated with the provided error information.</returns> /// <example> /// <code> /// public TypedResult&lt;User&gt; GetUser(int id) @@ -140,7 +142,7 @@ public class TypedResult<T> where T : new() /// </code> /// </example> public static TypedResult<T> Failure(ErrorDetail error) => new(error); - + /// <summary> /// Creates a new <see cref="TypedResult{T}"/> representing a failed operation /// with the specified error information. @@ -149,7 +151,7 @@ public class TypedResult<T> where T : new() /// <param name="msg">A human-readable message describing the failure.</param> /// <param name="e">An optional exception that caused the failure. Can be <c>null</c>.</param> /// <returns>A <see cref="TypedResult{T}"/> instance with <see cref="IsSuccess"/> set to <c>false</c> - /// and <see cref="ErrorDetail"/> populated with the provided error information.</returns> + /// and <see cref="_error"/> populated with the provided error information.</returns> /// <example> /// <code> /// public TypedResult&lt;User&gt; GetUser(int id) @@ -167,4 +169,16 @@ public class TypedResult<T> where T : new() /// </code> /// </example> public static TypedResult<T> Failure(int code, string msg, Exception? e) => new(new ErrorDetail(code, msg, e)); + + public T TryGetValue() + { + if (IsFailure) throw new InvalidOperationException("Can't get value: Result state is failure!"); + return _value ?? throw new NullReferenceException("TypedResult.Value is null"); + } + + public ErrorDetail TryGetError() + { + if (IsSuccess) throw new InvalidOperationException("Can't get error: Result state is success!"); + return _error ?? throw new NullReferenceException("TypedResult.Error is null"); + } } \ No newline at end of file diff --git a/Tests/Results/ResultTests.cs b/Tests/Results/ResultTests.cs @@ -0,0 +1,117 @@ +using AwesomeAssertions; +using SimpleResults.Errors; +using SimpleResults.Results; + +namespace Tests.Results; + +public class ResultTests +{ + [Fact] + public void Success_ShouldCreateSuccessfulResult() + { + // Act + var result = Result.Success(); + + // Assert + result.IsFailure.Should().BeFalse(); + } + + [Fact] + public void Failure_WithErrorDetail_ShouldCreateFailedResult() + { + // Arrange + const int errorCode = 404; + const string message = "Not found"; + var errorDetail = new ErrorDetail(errorCode, message); + + // Act + var result = Result.Failure(errorDetail); + + // Assert + result.IsFailure.Should().BeTrue(); + var error = result.TryGetError(); + error.ErrorCode.Should().Be(errorCode); + error.Message.Should().Be(message); + } + + [Fact] + public void Failure_WithCodeMessageAndException_ShouldCreateFailedResult() + { + // Arrange + var exception = new InvalidOperationException("Test exception"); + const int code = 500; + const string message = "Internal error"; + + // Act + var result = Result.Failure(code, message, exception); + + // Assert + result.IsFailure.Should().BeTrue(); + var error = result.TryGetError(); + error.ErrorCode.Should().Be(code); + error.Message.Should().Be(message); + error.Exception.Should().Be(exception); + } + + [Fact] + public void Failure_WithCodeMessageAndNullException_ShouldCreateFailedResult() + { + // Act + const int code = 400; + const string message = "Bad request"; + var result = Result.Failure(code, message, null); + + // Assert + result.IsFailure.Should().BeTrue(); + var error = result.TryGetError(); + error.ErrorCode.Should().Be(code); + error.Message.Should().Be(message); + error.Exception.Should().BeNull(); + } + + [Fact] + public void TryGetError_OnSuccessfulResult_ShouldThrowNullReferenceException() + { + // Arrange + var result = Result.Success(); + + // Act & Assert + var exception = Assert.Throws<InvalidOperationException>(() => result.TryGetError()); + Assert.Contains("Can't get error", exception.Message); + } + + [Fact] + public void IsFailure_OnFailedResult_ShouldReturnTrue() + { + // Arrange + const int code = 400; + const string message = "Bad request"; + var result = Result.Failure(code, message, null); + + // Act & Assert + Assert.True(result.IsFailure); + } + + [Fact] + public void MultipleFailureCalls_ShouldCreateIndependentInstances() + { + // Arrange + const int code1 = 400; + const string message1 = "Error 1"; + const int code2 = 500; + const string message2 = "Error 2"; + var error1 = new ErrorDetail(code1, message1); + var error2 = new ErrorDetail(code2, message2); + + // Act + var result1 = Result.Failure(error1); + var result2 = Result.Failure(error2); + + // Assert + Assert.NotSame(result1, result2); + Assert.True(result1.IsFailure); + Assert.True(result2.IsFailure); + Assert.Equal(code1, result1.TryGetError().ErrorCode); + Assert.Equal(code2, result2.TryGetError().ErrorCode); + } +} +\ No newline at end of file diff --git a/Tests/Results/TypedResultTests.cs b/Tests/Results/TypedResultTests.cs @@ -0,0 +1,122 @@ +using AwesomeAssertions; +using SimpleResults.Errors; +using SimpleResults.Results; + +namespace Tests.Results; + +public class TypedResultTests +{ + [Fact] + public void Success_ShouldCreateSuccessfulResult() + { + // Arrange + const string value = "Success"; + + // Act + var result = TypedResult<string>.Success(value); + + // Assert + result.IsFailure.Should().BeFalse(); + result.TryGetValue().Should().Be(value); + } + + [Fact] + public void Failure_WithErrorDetail_ShouldCreateFailedResult() + { + // Arrange + const int errorCode = 404; + const string message = "Not found"; + var errorDetail = new ErrorDetail(errorCode, message); + + // Act + var result = TypedResult<string>.Failure(errorDetail); + + // Assert + result.IsFailure.Should().BeTrue(); + var error = result.TryGetError(); + error.ErrorCode.Should().Be(errorCode); + error.Message.Should().Be(message); + } + + [Fact] + public void Failure_WithCodeMessageAndException_ShouldCreateFailedResult() + { + // Arrange + var exception = new InvalidOperationException("Test exception"); + const int code = 500; + const string message = "Internal error"; + + // Act + var result = TypedResult<string>.Failure(code, message, exception); + + // Assert + result.IsFailure.Should().BeTrue(); + var error = result.TryGetError(); + error.ErrorCode.Should().Be(code); + error.Message.Should().Be(message); + error.Exception.Should().Be(exception); + } + + [Fact] + public void Failure_WithCodeMessageAndNullException_ShouldCreateFailedResult() + { + // Act + const int code = 400; + const string message = "Bad request"; + var result = TypedResult<string>.Failure(code, message, null); + + // Assert + result.IsFailure.Should().BeTrue(); + var error = result.TryGetError(); + error.ErrorCode.Should().Be(code); + error.Message.Should().Be(message); + error.Exception.Should().BeNull(); + } + + [Fact] + public void TryGetError_OnSuccessfulResult_ShouldThrowNullReferenceException() + { + // Arrange + const string value = "Success"; + var result = TypedResult<string>.Success(value); + + // Act & Assert + var exception = Assert.Throws<InvalidOperationException>(() => result.TryGetError()); + Assert.Contains("Can't get error", exception.Message); + } + + [Fact] + public void IsFailure_OnFailedResult_ShouldReturnTrue() + { + // Arrange + const int code = 400; + const string message = "Bad request"; + var result = TypedResult<string>.Failure(code, message, null); + + // Act & Assert + Assert.True(result.IsFailure); + } + + [Fact] + public void MultipleFailureCalls_ShouldCreateIndependentInstances() + { + // Arrange + const int code1 = 400; + const string message1 = "Error 1"; + const int code2 = 500; + const string message2 = "Error 2"; + var error1 = new ErrorDetail(code1, message1); + var error2 = new ErrorDetail(code2, message2); + + // Act + var result1 = TypedResult<string>.Failure(error1); + var result2 = TypedResult<string>.Failure(error2); + + // Assert + Assert.NotSame(result1, result2); + Assert.True(result1.IsFailure); + Assert.True(result2.IsFailure); + Assert.Equal(code1, result1.TryGetError().ErrorCode); + Assert.Equal(code2, result2.TryGetError().ErrorCode); + } +} +\ No newline at end of file diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj @@ -0,0 +1,26 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net9.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <IsPackable>false</IsPackable> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="AwesomeAssertions" Version="9.2.1" /> + <PackageReference Include="coverlet.collector" Version="6.0.2"/> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/> + <PackageReference Include="xunit" Version="2.9.2"/> + <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/> + </ItemGroup> + + <ItemGroup> + <Using Include="Xunit"/> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\SimpleResults\SimpleResults.csproj" /> + </ItemGroup> + +</Project>