You hit on good points but you sometimes miss a second option. The gist of my response to your question is that it can be done, it just requires effort to implement exactly what you want.
In your controller or other handler
You didn't explicitly claim otherwise, but I do want to point out here that different kinds of validation exist and belong in different locations.
For example, a controller should validate parseability (e.g. can "2020-06-12" be parsed back to a valid date?) whereas your business layer should validate the business needs (e.g. is
2020-06-12 in the allowed period for this user?)
Pass the data to the object constructor and let it perform any necessary integrity checks, throwing an exception if any issues are found.
The main issue I have with this is the lack of detail returned to the caller explaining what happened.
As much as "flow by exception" should generally be avoided, exceptions certainly don't lack detail. If you take this route, then your exception should be some kind of validation exception type which extends the exception class with all of the information you need to know about the validation failure.
The issue with this one is that you can't guarantee that the Validator will always be used.
You can, but it requires more effort. You could create a result class (e.g.
ValidatedResult<T>) which effectively wraps a single value (i.e. the
T). If you make sure that only the validator can instantiate this class (using nested classes or access modifiers), then you can guarantee that any
ValidatedResult<T> object has been processed by a validator.
This makes sense in cases where each
T has one type of validation, because otherwise you still can't be sure if your
T was validated using the specific validation you're expecting it to be.
To further solve that issue of having multiple kinds of validation for a type, you can start extending these result types to explicitly specify which validation they belong to (e.g.
ContainsNoProfanityValidationResult : ValidationResult<string>).
As you can see, this starts requiring more and more effort to implement, but it does give you stricter control and more solid guarantees, which you're specifically looking for.
However, I somewhat disagree about the necessity of doing it so strictly though. There's a difference between guarding against malevolent attacks and guarding against developer forgetfulness. I presume only the latter is really applicable here, and this should generally be caught with unit tests as validation failures lead to changes in public behavior (i.e. refusing to perform a requested action), which tests can and should catch.
In a loosely-typed language, you could return either the object or the ValidationResult. That's less messy
I disagree. If you look at the big picture, loose typing is messier than strong typing. However, strict typing requires a bit more pedantry to satisfy the compiler. That's not messy, it just takes a bit of effort - but it pays back dividends in any sufficiently large codebase.
I would say that any codebase in which you're worried about forgetting to use a validation (enough so that you want a preventative architecture) is more than big enough for strong typing to pay those dividends in the long run.
you could return either the object or the ValidationResult.
This circles back to my earlier point, "validation result" should include both the successes and the failures! Always return a validation result, and then inspect it to see if it contains a success.
Semantics matter here. Boiled down to its core essence, validation does not need to give you back the value you put in (since you already knew it), it just needs to tell you if it passes the validation or not. For a basic validation algorithm, there's no need to return the object you already passed into it.
However, if you do take the time and effort to encapsulate your values in a validation result (presumably with an additional boolean to confirm that it's indeed a success), then you can both:
- Rest easy knowing that this value was already successfully validated and certified, allowing your domain to essentially require parameter values that were already validated
- Reusably log validation failures and report the actual value that was being used.
Given the concerns that you listed in the question, using a validation result is a win-win here.