PMQ.Notifications
1,864 downloads · the most adopted of the suite · repository
How a business rule complains, in a .NET system, accumulating what is wrong instead of throwing an exception at the first problem.
The problem
“Refund amount greater than what is available” is a foreseen outcome: the program anticipated that this can happen and has an opinion about it. Handling it with an exception has two costs: it interrupts at the first problem, when the screen wants to show the five wrong fields at once; and it erases the difference between “the customer got the tax ID wrong” and “the database went down”, which deserve completely different logging, alerting and handling.
The choice is written in the code itself:
/// Accumulates rule violations instead of throwing, so that a broken business rule stays an
/// expected outcome rather than exceptional control flow. Callers check IsValid and decide
/// what to do with ValidationResult.
public abstract class Validatable
{
[JsonIgnore] public ValidationResult ValidationResult { get; } = new();
[JsonIgnore] public bool IsValid => ValidationResult.IsValid;
[JsonIgnore] public bool IsInvalid => !IsValid;
}
Three decisions I stand by here
The category is a value object, not an enum. An enum closes the set: whoever consumes the
package cannot add a category without changing the library. Here NotificationType is a sealed
class with static instances: Validation, BusinessRule, NotFound, InconsistentState,
AccessDenied, plus a Custom(value) for the case I did not foresee:
public sealed class NotificationType : IEquatable<NotificationType>
{
public static readonly NotificationType BusinessRule = new("BUSINESSRULE");
public static readonly NotificationType NotFound = new("NOTFOUND");
public static NotificationType Custom(string value) => new(value);
}
It costs more code than an enum and pays off the first time someone needs a category the library
does not have. Equality by value, with == and GetHashCode, so the type behaves like data and
not like a reference.
[JsonIgnore] on the validation state. A small detail with a real consequence: without it, the
object serialized in an API response carries the list of internal errors along. It is the kind of
leak nobody notices until it shows up in the production payload.
Built on FluentValidation’s ValidationResult, and not on a type of its own. You lose
independence and gain interoperability with the validator practically every .NET project already
uses, because whoever already writes validators does not learn a second vocabulary.
The weakness I accept
Nothing forces the caller to check IsValid. Forgetting produces silent success, which is the worst
kind of defect. A Result type would have the compiler as a guarantee, but it contaminates every
signature along the way and would force living with two conventions in the same code, which is
worse than either of the two.
In practice the weakness ended up solved from another side: when PMQ.Mediator gained a pipeline, the check started happening in a stage before the handler, and the use case does not even run if the notifications are not clean.
Why it is the most downloaded of the suite
Because it is the most universal problem. Every system with business rules needs to decide how the rule complains, and the idiomatic .NET answer, which is throwing an exception, is the wrong one for most cases.