Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Question] How to handle different Error types in railway chain #553

Open
DerStimmler opened this issue Jul 22, 2024 · 6 comments
Open

[Question] How to handle different Error types in railway chain #553

DerStimmler opened this issue Jul 22, 2024 · 6 comments

Comments

@DerStimmler
Copy link
Contributor

Since CSharpFunctionalExtensions supports a custom error type other than string via the Result<T,E> type, I was wondering how to handle different error types in a result chain because there can be only one type for E.

Lets say I want to update a user.

I have a Service with those methods:

class UserService()
{
    public Result<User?, NotFoundError> Find(string id)
    {
        ...
    }

    public UnitResult<BadRequestError> UpdateAge(User user, int age)
    {
        ...
    }
}

So both methods return different errors.

In my endpoint I'd like to do that:

var result = service.Find("id")  //Result<User?, NotFoundError>
        .Check(user => service.UpdateAge(user, 20));  //UnitResult<BadRequestError>

But it's not possible because of the different error types.

Does anyone has an idea how to handle these use cases?


At the moment I can think of three ways on how to solve this.

First: Abstract all error types via an interface and use the abstract type in the chain.
When all error types implement IError I could do something like this:

var result = UnitResult.Success<IError>()
        .Bind(() => service.Find("id"))
        .Check(user => service.UpdateAge(user, 20));

Second: I could use another library like OneOf that provides support for union types.
Then the Result type in the chain would be Result<T, OneOf<NotFoundError,BadRequestError>>.

    var result = UnitResult.Success<OneOf<NotFoundError, BadRequestError>>()
        .Bind(() => service.Find("id"))
        .Check(user => service.UpdateAge(user, 20));

But that seems to make problems because inside the Bind method the NotFoundError cannot be implicitly casted to OneOf<NotFoundError, BadRequestError>.

Third: A big chain ist not possible. I rather have to split the chain each time the error type changes. On the split I have to check the .IsFailure property on the result. Handle the error case and otherwise start a new chain with the previous value and new error type.

    var service = new UserService();
    
    var tempResult = service.Find("id");
        
    if(tempResult.IsFailure)
        return ...;
        
    var result = Result.Success<User, BadRequestError>(tempResult.Value)
        .Check(user => service.UpdateAge(user, 20));
@hankovich
Copy link
Collaborator

Hi, yeap, that's kind a common issue. Regarding your ways to solve it:

  1. I'm not a fan of IError, since you cannot understand all possible errors method returns. I prefer closed hierarchy or concrete type, so a consumer can handle each error individually
  2. OneOf is a very nice library. It tries to create discriminated unions (or either monad) in C#, but CSharpFunctionalExtensions does the same! So OneOf<T1, T2> is basically Result<T1, T2>. And it starts to bit a bit weird to return Result<Result>. Moreover, I think returning OneOf<> as a result brakes incapsulation, because later you can add UpdateName call to your service and you don't want to return OneOf<T1, T2, T3> as an error
  3. Big chains are possible! In my project we have chains with up to 10 steps without any readability issues.

So how to handle this situations:

// 1. you want to return BadRequestError as error type

var result = service.Find("id")  //Result<User?, NotFoundError>
    .MapError(MapNotFoundErrorToBadRequestError) //Result<User?, BadRequestError>
    .Check(user => service.UpdateAge(user, 20));  //UnitResult<BadRequestError>

// 2. you want to return NotFoundError as error type

var result = service.Find("id")  //Result<User?, NotFoundError>
    .Check(user => service.UpdateAge(user, 20).MapError(MapBadRequestErrorToNotFoundError)); //UnitResult<NotFoundError>

And I have just a couple of thoughts about the general design:

  1. returning Result<User?, NotFoundError> is a bit weird, Result<User, NotFoundError> looks much nicer
  2. BadRequestError sounds too HTTP-specific, your application layer should not know anything about transfer protocols such as HTTP

@rutkowskit
Copy link
Contributor

Hello,

If you do now want to map errors, then another way to solve this problem is as follows:

public enum ErrorCodes
{
    NotFound = 400,
    BadRequest = 501
}
public record ServiceError(ErrorCodes Code, string Message);

class UserService()
{
    public void DoTest()
    {
        var result = Find("id")
            .EnsureNotNull(new ServiceError(ErrorCodes.BadRequest, "User data not present"))
            .Check(user => UpdateAge(user, 20));
    }
    
    public Result<User?, ServiceError> Find(string id)
    {
        return Result.Failure<User?, ServiceError>(new ServiceError(ErrorCodes.NotFound, "User not found"));
    }

    public UnitResult<ServiceError> UpdateAge(User user, int age)
    {
        return UnitResult.Failure(new ServiceError(ErrorCodes.BadRequest, "Age is invalid"));
    }

Instead of enum with pseudo-error-codes you can abstract out ServiceError base class, inherit all your Errors from it, and then change error wrapper to this:

public record ServiceError(ServiceError Error, string Message);

@DerStimmler
Copy link
Contributor Author

@rutkowskit If I'm not mistaken, your approach is pretty similar to my first suggestion, isn't it?
But instead of an IError interface you abstract the errors with a ServiceError base class.

@rutkowskit
Copy link
Contributor

@rutkowskit If I'm not mistaken, your approach is pretty similar to my first suggestion, isn't it? But instead of an IError interface you abstract the errors with a ServiceError base class.

Yes. If you need to keep information about the error type or error code, then this approach allows it. You will not lose the ‘error type’ between steps.
This approach will also allow you to easily map the error to a different type (such as one of the IResult types in minimal APIs).

@DerStimmler
Copy link
Contributor Author

DerStimmler commented Jul 23, 2024

@hankovich thanks for your quick reply.

I totally agree with your two thoughts at the end. This was just some demo code I quickly wrote down to show the problem in this issue. Maybe not the best demo code 😄

The drawback of your suggestion is that I have to choose one of the errors which gets returned by the chain.
But I want the corresponding error type based on which operation in the chain failed. If the service.Find() operation fails my Result error should be of type NotFoundError. It the service.UpdateAge() operation fails it should be a BadRequestError.


For context:
I'm currently implementing a library called CSharpFunctionalExtensions.HttpResults that allows you to map your Results to HttpResults.
If you use custom error types via Result<T,E> you can provide a mapper class that maps E to a HttpResult. The library then generates extension methods via SourceGeneration that use this mapper to map Results to HttpResults. An example is shown here.

This way you could write the following in your endpoint:

return service.Find("id") //Result<User?, NotFoundError>
	.ToOkHttpResult(); //Results<Ok<User>, NotFound>

In case the result is a success, the method would return Ok<User>. Otherwise, it would return a HttpResult based on your mapping logic for NotFoundError, e.g. a NotFound.

Now if I have this code:

return service.Find("id")  //Result<User?, NotFoundError>
        .Check(user => service.UpdateAge(user, 20));  //UnitResult<BadRequestError>
	    .ToOkHttpResult(); //Results<Ok<User>, NotFound, BadRequest>

I would like the method to return Ok<User> in case of a success. Otherwise, it would return HttpResult based on mapping logic for NotFoundError (e.g. NotFound) if the first operation fails, and a HttpResult based on the mapping logic for BadRequestError (e.g. BadRequest) if the second operation fails.
So the endpoint would return Results<Ok<User>,NotFound, BadRequest>.

But I can't use .MapError for this because both potential error types need to reach the ToOkHttpResult method.

If I abstract the errors using the IError interface or ServiceError base class (based on @rutkowskit suggestion), I could use Result<T,IError> in my chain and then create a mapper for IError that checks the concrete type and maps it to a specific HttpResult like:

error switch //IError
        {
            NotFoundError notFoundError => TypedResults.NotFound(),
            BadRequestError badRequestError => TypedResults.BadRequest(),
        }

@Uli-Armbruster
Copy link

Any news on that?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants