Open source

PMQ.Notifications

level applied-in-production

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.

Pablo Mickael Quevedo Senior Software Engineer · Novo Hamburgo, Brazil