ardalis / Result

A result abstraction that can be mapped to HTTP response codes if needed.
MIT License
847 stars 100 forks source link

Change the Status to Failed when any ValidationError is added to the Result. #161

Closed Ewerton closed 6 months ago

Ewerton commented 7 months ago

I'm new using the Result so, first of all: congratulation and thanks for this amazing lib.

I like to develop my methods using the following pattern:

Seems like the Result don't like this approach because it forces me to define the Status at the creation of the Result, like so:

Although, I tried to follow the pattern and discovered that I can instantiate the Result without defining the Status using new Result(...) but I found a "strange" behavior, here is the code:

public static void SomeMethod()
{
    Customer customer = new Customer() { Name = "", Email = "some@email.com" };
    Result<Customer> result = new Result<Customer>(customer); // I can instantiate a Result without defining the status and I know that the default is *Ok*
    var valueBefore = result.IsSuccess; // Correct. I didn't added any error yet
    result.ValidationErrors.Add(new ValidationError("Error 1"));
    var valueAfter = result.IsSuccess; // Incorrect. I added an error, so it should be false
}

I don't know if this is a bug but it would be more concise if the Status get changed to something like Invalid ou Error when any ValidationError is added to the Result. Is it possible or plausible?

ardalis commented 7 months ago

Error is meant for exceptions typically, so if you add any validation errors I would expect the status to update to Invalid. That certainly seems like something we could add without too much trouble (and hopefully without breaking things for any existing users). It would be weird (to me) for someone to create a Success result and also add validation errors to it while expecting it to remain a Success, but I obviously can't see how everyone is using this library.

I'd be open to changing the behavior to that when adding a validation error the status changes to invalid, but I'm also not sure the current design will easily support that. I unfortunately broke my own advice and did not encapsulate the validation errors property - it's just an exposed List<ValidationError> which means there is no way as it stands currently to hook into any of the List methods (like Add or Clear).

To implement this change we would need to:

// Result.cs currently
        public List<ValidationError> ValidationErrors { get; protected set; } = new List<ValidationError>();

Does that make sense and do you see a simpler way to do it?

Ewerton commented 7 months ago

I was hard thinking about this in the last days and I always ended-up asking myself: Why I can change the ValidationErros but can't change the Errors property? What if I could? What would be the implications? I always tend to like the conciseness of the libraries, I mean, the library should offer a standardized way of instantiating and interacting with it kind of "guiding" the user and thus, providing some "intuitiveness". It made me think that, for the Result lib, we could think in two scenarios:

1 Only allow the user to instantiate it from the static constructors

PROS

2 Allow the user to instantiate it the way he likes

PROS

I really like the option 2 and I'm working to get it work like described here.

PS: As I don't know the library in deep, and I don't know how breaking this second option would be. Right now my implementation does not break any test of the solution.

KyleMcMaster commented 7 months ago

Error is meant for exceptions typically, so if you add any validation errors I would expect the status to update to Invalid. That certainly seems like something we could add without too much trouble (and hopefully without breaking things for any existing users). It would be weird (to me) for someone to create a Success result and also add validation errors to it while expecting it to remain a Success, but I obviously can't see how everyone is using this library.

I'd be open to changing the behavior to that when adding a validation error the status changes to invalid, but I'm also not sure the current design will easily support that. I unfortunately broke my own advice and did not encapsulate the validation errors property - it's just an exposed List<ValidationError> which means there is no way as it stands currently to hook into any of the List methods (like Add or Clear).

To implement this change we would need to:

  • Refactor ValidationErrors to be a custom ValidationErrorList : List<ValidationError> type and use that for the property
  • When creating the custom collection, pass a reference to the Result instance into its constructor
  • Hook into the Add method and have it check the parent Result type's status
  • Update the status to Invalid if the status is Success
// Result.cs currently
        public List<ValidationError> ValidationErrors { get; protected set; } = new List<ValidationError>();

Does that make sense and do you see a simpler way to do it?

I believe ValidationErrors shouldn't be exposed as a List but as an IEnumerable<ValidationError> or IReadOnlyCollection<ValidationError>. @ardalis has a great article on this here. This would prevent modifying the collection directly on an instance of a Result, keeping Result immutable and semi-functional (like the Map feature). Instead, users who would like to modify the ValidationErrors collection would be encouraged to instantiate a new Result rather than changing state of an existing result. I'm thinking something like this:

var successResult = Result.Success();

var validationErrors = successResult.ValidationErrors.ToList();
validationErrors.Add(new ValidationError { Identifier = "Foo", ErrorMessage = "Invalid Foo for some reason" });

var invalidResult = Result.Invalid(validationErrors);

This prevents an existing Result from transitioning to a corrupted state on accident, like a successful Result with a ValidationError count greater than zero. Another benefit is this also keeps the library code simple since the Result can stay agnostic of any external manipulation in user code. Just weighing in on some thoughts I had about this.

Edit: Note that in any approach, any changes made to how ValidationErrors are handled should probably be considered a breaking change as well for versioning purposes.

Ewerton commented 7 months ago

Hi @KyleMcMaster

I agree with your opinion, it is somehow what I described in "option 1" above. Like I said, this design is less flexible and restricts the way the user interact with the lib, but it is very straightforward (which is good).

I do not fully agree with you when you say:

This prevents an existing Result from transitioning to a corrupted state on accident, like a successful Result with a ValidationError count greater than zero

We can prevent this corrupted state with proper coding and automated testing like I did in the PR.

Also, I read the article you mentioned and, from my understanding, it is more focused in Domain Driven Design which it is an effective way to design large and complex business rules and, "maybe" not so good to design a utility library as Result.

Current Situation

Right now the lib allows editing the ValidationError but does not allow editing the Errors property. So, we have two options to make the design more concise:

  1. Restrict the editing of the ValidationErrors property.
  2. Accept my PR to allow editing both ValidationErrors and Errors properties.

Both are breaking changes

ardalis commented 7 months ago

I think it was a design error of mine to expose ValidationErrors as just a List. I would opt for your option 1 as my breaking change preference.

Ewerton commented 7 months ago

Ok. Should I implement and submit a PR to turn List<ValidationError> ValidationErrors into IEnumerable<ValidationError> ValidationErrors ?

Also, please reject the previous PR.

ardalis commented 6 months ago

Oops I forgot about this conversation and took in that PR, but we will make another PR here soon to fix everything up.