← tutti gli articoli

C#

Usare il Result Pattern in C# invece di restituire null

Il Result Pattern rende espliciti gli stati previsti di successo e fallimento, offrendo un’alternativa più chiara alla restituzione di null o al lancio di eccezioni per i normali esiti di business.

Ho imparato il Result Pattern lavorando con .NET e ha cambiato il mio modo di considerare i metodi che possono fallire. Invece di restituire null e lasciare al chiamante il compito di interpretarlo, il metodo restituisce un oggetto che descrive un successo oppure un fallimento.

Questo non significa che ogni valore nullable o ogni eccezione debba scomparire. Il pattern è particolarmente utile quando il fallimento è un esito previsto e il chiamante deve capire che cosa è successo.

Il limite della restituzione di null

Consideriamo un metodo di un repository che restituisce null quando non riesce a trovare un utente. Il chiamante può verificare la presenza di null, ma il valore non ne spiega il motivo. L’identificatore non era valido? L’utente non esisteva? Un’altra validazione non è riuscita?

I tipi riferimento nullable migliorano questa situazione permettendoci di esprimere se un riferimento è destinato ad accettare null. Il compilatore C# usa quindi l’analisi statica per segnalare possibili assegnazioni e dereferenziazioni di null. Tuttavia, si tratta di una funzionalità di compile-time; non crea un tipo runtime separato né aggiunge una validazione runtime. (learn.microsoft.com)

Un Result<T> aggiunge significato all’esito dell’operazione. Invece di restituire User?, un metodo può restituire un Result<User> contenente l’utente oppure un errore specifico.

Rappresentare gli errori

La prima parte è un piccolo tipo per gli errori:

public record Error(string Code, string Message)
{
    public static Error None = new(string.Empty, string.Empty);
    public static Error NullValue = new("Error.NullValue", "Um valor nulo foi fornecido.");
}

Il codice fornisce un identificatore stabile per le decisioni programmatiche e un messaggio che può essere registrato o visualizzato. Error.None rappresenta un’operazione riuscita, in cui non è presente alcun errore.

Creare il tipo Result

Il tipo Result non generico rappresenta operazioni che hanno successo senza restituire un valore:

public class Result
{
    protected Result(bool isSuccess, Error error)
    {
        switch (isSuccess)
        {
            case true when error != Error.None:
                throw new InvalidOperationException();

            case false when error == Error.None:
                throw new InvalidOperationException();

            default:
                IsSuccess = isSuccess;
                Error = error;
                break;
        }
    }

    public bool IsSuccess { get; }
    public bool IsFailure => !IsSuccess;
    public Error Error { get; }

    public static Result Success() => new(true, Error.None);
    public static Result Failure(Error error) => new(false, error);

    public static Result<T> Success<T>(T value) => new(value, true, Error.None);
    public static Result<T> Failure<T>(Error error) => new(default, false, error);

    public static Result<T> Create<T>(T? value) =>
        value is not null ? Success(value) : Failure<T>(Error.NullValue);
}

Il costruttore protegge due regole importanti: un risultato riuscito non può contenere un errore e un risultato fallito deve contenerne uno. Mantenere validi questi stati evita oggetti contraddittori, come un risultato riuscito con un messaggio di errore.

Per le operazioni che restituiscono dati, la versione generica memorizza il valore restituito in caso di successo:

public class Result<T> : Result
{
    private readonly T? _value;

    protected internal Result(T? value, bool isSuccess, Error error) : base(isSuccess, error)
        => _value = value;

    [NotNull]
    public T Value => _value! ?? throw new InvalidOperationException("Result has no value");

    public static implicit operator Result<T>(T? value) => Create(value);
}

La proprietà Value è disponibile per i risultati riusciti. Accedervi dopo un fallimento genera un’InvalidOperationException, quindi chi utilizza il risultato dovrebbe prima verificarne lo stato. Il pattern rende il contratto più chiaro, ma dipende comunque da un utilizzo corretto nel punto di chiamata.

Restituire un utente senza restituire null

Un metodo di ricerca può ora comunicare esplicitamente diversi fallimenti previsti:

public Result<User> GetUserById(int userId)
{
    if (userId <= 0)
        return Result.Failure<User>(Error.InvalidUserId);

    var user = _userRepository.FindById(userId);
    if (user == null)
        return Result.Failure<User>(Error.UserNotFound);

    return Result.Success(user);
}

Supponendo che InvalidUserId e UserNotFound siano definiti nel catalogo degli errori, il tipo restituito comunica a chi lo utilizza che il metodo presenta due stati possibili. Il chiamante può gestirli direttamente:

var result = GetUserById(123);

if (result.IsSuccess)
{
    Console.WriteLine($"User found: {result.Value.Name}");
}
else
{
    Console.WriteLine($"Failed to retrieve user: {result.Error.Message}");
}

Questo approccio è più descrittivo rispetto al controllo per verificare se un User restituito è null. Inoltre, mantiene visibili i risultati di business previsti nel normale flusso di controllo.

Result non sostituisce le eccezioni

Le eccezioni rimangono appropriate per condizioni impreviste che non possono essere gestite nell’ambito della normale operazione. Le linee guida di .NET raccomandano di evitare le eccezioni per le condizioni di routine, utilizzando invece la gestione delle eccezioni per eventi realmente eccezionali. (learn.microsoft.com)

Una distinzione pratica è la seguente:

  • Restituire un risultato per errori di validazione, record mancanti, conflitti e altri esiti previsti.
  • Generare eccezioni per errori di programmazione, stati dell’oggetto non validi o errori infrastrutturali imprevisti che il livello corrente non può gestire in modo significativo.

Lo scopo principale del Result Pattern non è eliminare ovunque null o le eccezioni. Consiste nell’assegnare un tipo esplicito ai fallimenti previsti e nel rendere più facili da comprendere i contratti dei metodi.

Due ulteriori trattazioni pratiche del pattern sono disponibili agli indirizzi https://www.red-gate.com/simple-talk/development/dotnet-development/the-result-pattern-in-asp-net-core-minimal-apis/ e https://medium.com/@emrecantopaloglu/the-result-pattern-in-net-a-simple-guide-73a8f1b89d73.

Riferimenti