Implementing Railway-Oriented Programming

In our previous tutorial, we learned about the basics of Railway-oriented programming in theory. It is time to apply our theoretical skills in practice.

We use Railway-oriented programming specially to avoid throwing exceptions when we deal with expected cases. Ok, but what is the expected case? Well, in a simple manner, if you expect your user to provide invalid data and it happens, it is going to be your expected case. Because users are not developers. We should expect our users to provide invalid data when consuming our application.

Railway Oriented Programming in C#

Railway Oriented Programming (ROP) is a concept that originated in functional programming and has gained attention in various programming paradigms, including C#. At its core, ROP is about structuring code to handle errors and complex flows in a clear and manageable way.

In traditional imperative programming, error handling often leads to deeply nested code, making it difficult to understand and maintain. ROP offers an alternative approach by modeling the flow of operations as a railway track. The track represents the successful path, while any deviation (error) switches to a different track.

How can ROP be beneficial in C#?

In C#, you can implement ROP using various techniques, such as using Result types, Railway-oriented programming libraries, or custom implementations. Here's how ROP can be beneficial in C#:

  • Clearer Error Handling: ROP separates the happy path from error handling, making it easier to understand the flow of the code.
  • Composability: ROP encourages breaking down complex operations into smaller, composable units, which can then be chained together to form larger operations. This improves code readability and reusability.
  • Reduced Cyclomatic Complexity: By avoiding nested conditional statements, ROP can help reduce cyclomatic complexity, leading to simpler and more maintainable code.
  • Improved Testability: ROP promotes the use of pure functions and small, testable units of code, which can lead to more comprehensive and easier-to-write tests.
  • Explicit Error Handling: Instead of relying on exceptions for error handling, ROP makes errors explicit, allowing you to handle them in a controlled manner.

Here is a snapshot from our previous tutorial:

public async Task<Account> AddAsync(Account account)
{
    bool isCustomerExist = await _customerService.IsExistsAsync(account.CustomerId);

    if (!isCustomerExist)
    {
        throw new Exception("Customer with given Id doesn't exist");
    }
    else
    {
        bool isAccountExist = await _accountRepository.IsExistsAsync(account.AccountNumber);

        if (isAccountExist)
        {
            await _unitOfWork.AddAsync(account);
            await _unitOfWork.SaveChangesAsync();
            return account;
        }
        else
        {
            throw new Exception("Account with given number doesn't exist");
        }
    }
}

When a customer doesn’t exist, we throw an exception. Users may provide data that is not valid and customer ID is one of them. It is an expected case for us. That is the main reason why you should avoid throwing exceptions in such types of cases.

The same applies to account numbers. When the account number is not valid, we raise an exception. This is also expected.

Railway-oriented programming says that we should provide both; success and failure response to the above layer and it should be a part of the contract.

public async Task<( Account, YourError)> AddAsync(Account account)

Railway-oriented programming makes your signature honest and anyone who reads the code will understand that there is nothing implicit and we may get errors depending on the provided data.

PS: check out our repository for more complete examples

Implementing Railway-oriented programming with Result type(pattern)

Result pattern(type) is one of the popular ways of implementing ROP ideas in C#. Luckily we have a lot of libraries out there that help us to apply it. My favorite one is csharpfunctionalextensions ; so I’ll use this library.

Go to Tools->nuget package manager->package manager console and install csharpfunctionalextensions.

Install-package csharpfunctionalextensions

This package provides a lot of classes to not just apply Result but also apply other functional programming ideas to C#.

We have an IResult interface that is responsible for bringing ROP to the table.

Let's change our method signature to IResult.

public async Task<IResult<Account, DomainError>> AddAsync(Account account)

When you use IResult or it is implementation(Result<>) don’t throw exceptions for expected cases, instead of this, replace all possible expected cases with Result type.

Let’s do it one by one.

To describe our errors in more detail, I’ll create a class called DomainError.

namespace Domain.Models
{
    public class DomainError
    {
        public string? ErrorMessage { get; }
        public ErrorType ErrorType { get; }

        public DomainError(string? errorMessage, ErrorType errorType)
        {
            ErrorMessage = errorMessage;
            ErrorType = errorType;
        }

        public static DomainError NotFound(string? message = "Given element not found") => new DomainError(message, ErrorType.NotFound);

        public static DomainError Conflict(string? message = "Conflict operation") => new DomainError(message, ErrorType.Conflict);
    }

    public enum ErrorType
    {
        NotFound,
        Conflict
    }
}

When a customer is not found, we need to return Result-based failure, not an exception.

bool isCustomerExist = await _customerService.IsExistsAsync(account.CustomerId);

            if (!isCustomerExist)
            {
                return Result.Failure<Account, DomainError>(DomainError.NotFound("Customer with given Id doesn't exist"));
            }

Result.Failure is the exact method we need to use when we want to notify our users about expected errors.

When the account number is not found, we need to remove the old code that throws an exception and replace it with Result based failure.

bool isAccountExist = await _accountRepository.IsExistsAsync(account.AccountNumber);

if (isAccountExist)
{
    await _unitOfWork.AddAsync(account);
    await _unitOfWork.SaveChangesAsync();
    return Result.Success<Account, DomainError>(account);
}
else
{
    return Result.Failure<Account, DomainError>(DomainError.NotFound("Account with given number doesn't exist"));
}

If everything is ok, Result.Success will return a valid account to our user.

Conclusion

ROP doesn’t mean that you should always prefer Result based response. Use both in your development. Exceptions are better for exceptional cases, and ROP is better for expected cases


Similar Articles