General Principles
1. Make Errors Explicit
1// ❌ Bad - Hidden errors
2public User GetUser(string id)
3{
4 try { return LoadUser(id); }
5 catch { return null; } // What went wrong?
6}
7
8// ✅ Good - Explicit errors
9public Result<User, string> GetUser(string id)
10{
11 return LoadUser(id)
12 .MapError(e => $"Failed to load user {id}: {e}");
13}
2. Use Meaningful Error Types
1// ❌ Bad - Generic strings
2Result<Data, string> LoadData() => "Error";
3
4// ✅ Good - Specific error types
5public enum DataError
6{
7 NotFound,
8 Corrupted,
9 AccessDenied
10}
11Result<Data, DataError> LoadData() => DataError.NotFound;
12
13// ✅ Better - Rich error objects
14public class DataError
15{
16 public ErrorCode Code { get; }
17 public string Message { get; }
18 public Dictionary<string, object> Context { get; }
19}
3. Chain Operations, Don't Nest
1// ❌ Bad - Nested if statements
2public Result<Order, string> ProcessOrder(Order order)
3{
4 if (ValidateOrder(order))
5 {
6 if (CheckInventory(order))
7 {
8 if (ChargePayment(order))
9 {
10 return ShipOrder(order);
11 }
12 return "Payment failed";
13 }
14 return "Out of stock";
15 }
16 return "Invalid order";
17}
18
19// ✅ Good - Chained operations
20public Result<Order, string> ProcessOrder(Order order)
21{
22 return ValidateOrder(order)
23 .Bind(CheckInventory)
24 .Bind(ChargePayment)
25 .Bind(ShipOrder);
26}
Result<T,E> Best Practices
1. Choose the Right Creation Method
1// Direct creation for known outcomes
2Result<int, string> Divide(int a, int b)
3{
4 return b == 0
5 ? "Cannot divide by zero" // Implicit failure
6 : a / b; // Implicit success
7}
8
9// Use Of() for exception-prone code
10Result<Config, string> LoadConfig()
11{
12 return Result.Of(
13 () => JsonUtility.FromJson<Config>(configJson),
14 ex => $"Config parse error: {ex.Message}"
15 );
16}
17
18// Use SuccessIf for boolean conditions
19Result<User, string> ValidateAge(User user)
20{
21 return Result.SuccessIf(
22 user.Age >= 18,
23 user,
24 "User must be 18 or older"
25 );
26}
2. Handle Both Success and Failure
1// ❌ Bad - Ignoring failure
2var result = LoadData();
3if (result.IsSuccess)
4 Process(result.Value);
5// What about failure?
6
7// ✅ Good - Handle both paths
8LoadData().Match(
9 onSuccess: data => { Process(data); return Unit.Value; },
10 onFailure: error => { LogError(error); return Unit.Value; }
11);
12
13// ✅ Also good - Chain with error handling
14LoadData()
15 .Map(Process)
16 .MapError(error => {
17 LogError(error);
18 return error;
19 });
3. Add Context to Errors
1// ❌ Bad - Generic errors
2public Result<Data, string> LoadUserData(string userId)
3{
4 return LoadFromDatabase(userId)
5 .Bind(ParseData)
6 .Bind(ValidateData); // Which step failed?
7}
8
9// ✅ Good - Contextual errors
10public Result<Data, string> LoadUserData(string userId)
11{
12 return LoadFromDatabase(userId)
13 .MapError(e => $"Database error for user {userId}: {e}")
14 .Bind(ParseData)
15 .MapError(e => $"Parse error for user {userId}: {e}")
16 .Bind(ValidateData)
17 .MapError(e => $"Validation error for user {userId}: {e}");
18}
Maybe<T> Best Practices
1. Use Maybe Instead of Null
1// ❌ Bad - Nullable references
2public User FindUser(string id)
3{
4 return users.FirstOrDefault(u => u.Id == id); // Can be null
5}
6
7// ✅ Good - Explicit optionality
8public Maybe<User> FindUser(string id)
9{
10 return users.TryFirst(u => u.Id == id);
11}
2. Chain Maybe Operations
1// ❌ Bad - Multiple null checks
2public string GetManagerEmail(string employeeId)
3{
4 var employee = FindEmployee(employeeId);
5 if (employee == null) return "No email";
6
7 var manager = employee.Manager;
8 if (manager == null) return "No email";
9
10 var email = manager.Email;
11 if (string.IsNullOrEmpty(email)) return "No email";
12
13 return email;
14}
15
16// ✅ Good - Chained Maybe
17public string GetManagerEmail(string employeeId)
18{
19 return FindEmployee(employeeId)
20 .Bind(e => e.Manager)
21 .Bind(m => m.Email)
22 .Where(e => !string.IsNullOrEmpty(e))
23 .Or("No email");
24}
3. Use Collection Helpers
1// ❌ Bad - Manual null checks
2var dict = GetSettings();
3if (dict.ContainsKey("ApiUrl"))
4{
5 var url = dict["ApiUrl"];
6 if (!string.IsNullOrEmpty(url))
7 {
8 UseUrl(url);
9 }
10}
11
12// ✅ Good - TryFind helper
13GetSettings()
14 .TryFind("ApiUrl")
15 .Where(url => !string.IsNullOrEmpty(url))
16 .Execute(UseUrl);
Async Best Practices
1. Use Async Methods When Available
1// ❌ Bad - Blocking async
2public Result<Data, string> LoadData()
3{
4 var task = LoadDataAsync();
5 return task.Result; // Blocks thread!
6}
7
8// ✅ Good - Async all the way
9public async UniTask<Result<Data, string>> LoadDataAsync()
10{
11 return await FetchFromServerAsync()
12 .Bind(ProcessDataAsync)
13 .Map(TransformDataAsync);
14}
2. Mix Sync and Async Appropriately
1// ✅ Good - Only async where needed
2public async UniTask<Result<User, string>> GetUserAsync(string id)
3{
4 return ValidateId(id) // Sync validation
5 .Bind(FetchUserAsync) // Async network call
6 .Map(TransformUser) // Sync transformation
7 .Tap(CacheUserAsync); // Async caching
8}
3. Handle Cancellation
1public async UniTask<Result<Data, string>> LoadDataAsync(CancellationToken ct)
2{
3 return await Result.OfAsync(
4 async () => {
5 ct.ThrowIfCancellationRequested();
6 var data = await FetchAsync(ct);
7 ct.ThrowIfCancellationRequested();
8 return await ProcessAsync(data, ct);
9 },
10 ex => ex is OperationCanceledException
11 ? "Operation cancelled"
12 : $"Error: {ex.Message}"
13 );
14}
1. Avoid Unnecessary Allocations
1// ❌ Bad - Creates intermediate Results
2public Result<string, Error> Process(string input)
3{
4 return Result<string, Error>.Success(input)
5 .Map(s => s.Trim())
6 .Map(s => s.ToUpper())
7 .Map(s => s.Replace(" ", "_"));
8}
9
10// ✅ Good - Single transformation
11public Result<string, Error> Process(string input)
12{
13 return Result<string, Error>.Success(
14 input.Trim().ToUpper().Replace(" ", "_")
15 );
16}
2. Use Structs for Error Types
1// ✅ Good - Struct for simple errors
2public readonly struct ValidationError
3{
4 public string Field { get; }
5 public string Message { get; }
6
7 public ValidationError(string field, string message)
8 {
9 Field = field;
10 Message = message;
11 }
12}
13
14// Use class only when needed (inheritance, large data)
15public class ComplexError : IError
16{
17 public List<string> Details { get; }
18 public Exception InnerException { get; }
19}
3. Cache Expensive Operations
1public class DataService
2{
3 private readonly Dictionary<string, Result<Data, Error>> cache = new();
4
5 public Result<Data, Error> GetData(string key)
6 {
7 return cache.TryGetValue(key, out var cached)
8 ? cached
9 : LoadData(key).Tap(result => cache[key] = result);
10 }
11}
Visual Debugging Best Practices
1. Use Meaningful Flow Names
1// ❌ Bad - Generic names
2result.EnableDebug("Flow1")
3result.EnableDebug("Process")
4
5// ✅ Good - Descriptive names
6result.EnableDebug($"LoadUser_{userId}")
7result.EnableDebug($"ProcessOrder_{orderId}_{timestamp}")
8result.EnableDebug($"PaymentFlow_{transactionId}")
2. Add Metadata for Context
1// ✅ Good - Rich debugging context
2public Result<Order, Error> ProcessOrder(OrderRequest request)
3{
4 return ValidateRequest(request)
5 .EnableDebug("ProcessOrder", new
6 {
7 OrderId = request.OrderId,
8 CustomerId = request.CustomerId,
9 Amount = request.TotalAmount,
10 ItemCount = request.Items.Count,
11 Timestamp = DateTime.Now
12 })
13 .Bind(ExecuteOrder);
14}
3. Debug Strategically
1// Debug at service boundaries
2public class OrderService
3{
4 public Result<Order, Error> CreateOrder(OrderRequest request)
5 {
6 return InternalCreateOrder(request)
7 .EnableDebug($"OrderService.CreateOrder");
8 }
9}
10
11// Debug complex operations
12public Result<Report, Error> GenerateComplexReport(ReportParams params)
13{
14 return LoadData(params)
15 .Bind(ValidateData)
16 .Bind(TransformData)
17 .Bind(AggregateData)
18 .Bind(FormatReport)
19 .EnableDebug($"ComplexReport_{params.ReportId}");
20}
21
22// Conditional debugging in production
23#if DEBUG || DEVELOPMENT_BUILD
24 .EnableDebug("DetailedFlow")
25#else
26 .EnableDebug("ProductionFlow", minimalMetadata)
27#endif
Testing Best Practices
1. Test Success and Failure Paths
1[Test]
2public void ProcessOrder_ValidOrder_ReturnsSuccess()
3{
4 var order = CreateValidOrder();
5 var result = ProcessOrder(order);
6
7 Assert.IsTrue(result.IsSuccess);
8 Assert.AreEqual(order.Id, result.Value.Id);
9}
10
11[Test]
12public void ProcessOrder_InvalidOrder_ReturnsExpectedError()
13{
14 var order = CreateInvalidOrder();
15 var result = ProcessOrder(order);
16
17 Assert.IsTrue(result.IsFailure);
18 Assert.AreEqual(OrderError.ValidationFailed, result.Error);
19}
2. Test Error Recovery
1[Test]
2public void LoadConfig_FileNotFound_UsesDefault()
3{
4 var result = LoadConfigWithFallback("nonexistent.json");
5
6 Assert.IsTrue(result.IsSuccess);
7 Assert.AreEqual("Default", result.Value.Name);
8}
3. Mock Result-Returning Methods
1public interface IUserService
2{
3 Result<User, Error> GetUser(string id);
4}
5
6[Test]
7public void TestWithMockedResult()
8{
9 var mockService = new Mock<IUserService>();
10 mockService
11 .Setup(s => s.GetUser("123"))
12 .Returns(Result<User, Error>.Success(new User { Id = "123" }));
13
14 // Test your code with mocked service
15}
Common Patterns
1. Repository Pattern
1public interface IRepository<T, TId>
2{
3 Result<T, RepositoryError> GetById(TId id);
4 Result<Unit, RepositoryError> Save(T entity);
5 Maybe<T> Find(Expression<Func<T, bool>> predicate);
6}
2. Validation Pipeline
1public static class Validation
2{
3 public static Result<T, ValidationError> Validate<T>(
4 T value,
5 params IValidator<T>[] validators)
6 {
7 return validators.Aggregate(
8 Result<T, ValidationError>.Success(value),
9 (result, validator) => result.Bind(validator.Validate)
10 );
11 }
12}
3. Error Aggregation
1public class ErrorAggregator<T>
2{
3 private readonly List<T> errors = new();
4
5 public Result<Unit, List<T>> ToResult()
6 {
7 return errors.Any()
8 ? Result<Unit, List<T>>.Failure(errors)
9 : Result<Unit, List<T>>.Success(Unit.Value);
10 }
11}
Anti-Patterns to Avoid
1. Don't Throw Inside Result Methods
1// ❌ Bad - Defeats the purpose
2public Result<Data, Error> Process(Input input)
3{
4 if (input == null)
5 throw new ArgumentNullException(); // Don't throw!
6
7 return ProcessInternal(input);
8}
9
10// ✅ Good - Return failure
11public Result<Data, Error> Process(Input input)
12{
13 if (input == null)
14 return new Error("Input cannot be null");
15
16 return ProcessInternal(input);
17}
2. Don't Access Value Without Checking
1// ❌ Bad - Can throw
2var result = LoadData();
3var processed = Process(result.Value); // Throws if failure!
4
5// ✅ Good - Check first or use Match
6var result = LoadData();
7result.Match(
8 onSuccess: data => { Process(data); return Unit.Value; },
9 onFailure: error => { HandleError(error); return Unit.Value; }
10);
3. Don't Mix Result and Exceptions
1// ❌ Bad - Inconsistent error handling
2public Result<Data, Error> LoadData()
3{
4 try
5 {
6 var data = GetData();
7 if (data == null)
8 return new Error("No data"); // Result error
9
10 return Process(data); // Might throw!
11 }
12 catch (Exception ex)
13 {
14 throw; // Now throwing exceptions!
15 }
16}
17
18// ✅ Good - Consistent Result usage
19public Result<Data, Error> LoadData()
20{
21 return Result.Of(GetData, ex => new Error(ex))
22 .Ensure(data => data != null, new Error("No data"))
23 .Bind(data => Result.Of(
24 () => Process(data),
25 ex => new Error(ex)
26 ));
27}
Summary
Following these best practices will help you:
- Write more maintainable code
- Handle errors consistently
- Improve performance
- Make debugging easier
- Create better tests
Remember: Make the success path and failure path equally important and explicit.