Immagine di copertina: Result Pattern: Gestione Funzionale degli Errori in C#

Result Pattern: Gestione Funzionale degli Errori in C#

2024/09/12

Le eccezioni in C# (e in molti altri linguaggi) sono fatte per scenari eccezionali, ma nella vita reale finiscono per gestire la metà del flusso normale: login fallito, validazione, dato non trovato. Il result pattern tratta gli errori come dati, non come interruzioni: il flusso resta esplicito, le performance migliorano, e il codice diventa più facile da leggere a sei mesi di distanza.

Implementazione

Esempio: aggiornare un'attività TODO. Con il result pattern modelliamo successo e fallimento come due strade dello stesso tipo di ritorno.

Il codice sotto è didattico. In produzione conviene quasi sempre usare una libreria battuta, fra cui:

public class Result<T>
{
    public bool IsSuccess { get; }
    public T Value { get; }
    public Error Error { get; }

    protected Result(bool isSuccess, T value, Error error)
    {
        IsSuccess = isSuccess;
        Value = value;
        Error = error ?? Error.None;
    }

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

    public static Result<T> Failure(Error error) => new Result<T>(false, default, error);

    // Conversione implicita da T a Result<T> (successo)
    public static implicit operator Result<T>(T value) => Success(value);

    // Conversione implicita da Error a Result<T> (fallimento)
    public static implicit operator Result<T>(Error error) => Failure(error);
}
// Rappresentazione errore
public sealed record Error(string Code, string Description)
{
    public static readonly Error None = new(string.Empty, string.Empty);

    public override string ToString() => $"{Code}: {Description}";
}

Ora vediamo l'uso concreto, accoppiato al Mediator pattern per separare la logica di business dall'infrastruttura ed evitare le referenze circolari.

public class UpdateToDoCommand : IRequest<Result<ToDoItem>>
{
    public int Id { get; set; }
    public string NewTitle { get; set; }

    public UpdateToDoCommand(int id, string newTitle)
    {
        Id = id;
        NewTitle = newTitle;
    }
}
public class UpdateToDoCommandHandler : IRequestHandler<UpdateToDoCommand, Result<ToDoItem>>
{
    private readonly IToDoRepository _repository;

    public UpdateToDoCommandHandler(IToDoRepository repository)
    {
        _repository = repository;
    }

    public async Task<Result<ToDoItem>> Handle(UpdateToDoCommand request, CancellationToken cancellationToken)
    {
        var toDoItem = await _repository.GetByIdAsync(request.Id);

        if (toDoItem == null)
        {
            //return Result<ToDoItem>.Failure(new Error("NOT_FOUND", "Item not found"));
            return new Error("NOT_FOUND", "Item not found");
        }

        toDoItem.UpdateTitle(request.NewTitle);
        await _repository.UpdateAsync(toDoItem);

        return toDoItem;
    }
}

L'handler usa il generico Result<T>: se l'item non esiste torna un fallimento con codice e messaggio, altrimenti aggiorna e torna l'item. Niente throw, niente try/catch, niente stack trace per casi prevedibili.

Notare che non serve istanziare il tipo di ritorno con Result<ToDoItem>.Failure o Result<ToDoItem>.Success: la conversione implicita definita sopra fa il lavoro. Meno codice, meno rumore.

Dove gestire il risultato

Il consiglio: tieni il Result Pattern nello strato vicino all'utente. Nel Controller, ad esempio, sai cosa farne: se è successo, 200 con il payload; se è fallimento, 4xx col messaggio.

// ...
public class ToDoController : ControllerBase
{
    // ...

    [HttpPost]
    public async Task<ActionResult> JustDoIt()
    {
        var command = new UpdateToDoCommand(1, "Nuovo Titolo");
        var result = await _mediator.Send(command);

        if (result.IsSuccess)
        {
            return Ok(result.Value.Title);
        }
        else
        {
            return BadRequest(result.Error);
        }
    }
}

Risultato: niente try/catch nel flusso principale, controllo esplicito su successo e fallimento, e leggibilità migliorata. Chi legge il codice in futuro vede subito cosa può andare storto e dove.

Result o Exception? Quando usare cosa

Il Result Pattern è il vestito giusto per i fallimenti prevedibili: login con password sbagliata, item inesistente, validazione fallita. Sono casi attesi, parte del normale ciclo della tua applicazione.

Le eccezioni servono per i casi imprevisti: connessione al DB caduta, file mancante, configurazione corrotta. Lì throw è legittimo perché l'errore non fa parte del flusso normale e probabilmente nessuno sa cosa fare oltre a fermarsi e loggare.

La regola in due righe: se il codice chiamante deve gestire il caso, è un Result. Se nessuno può gestirlo realmente sul posto, è un'Exception.

In sostanza

Gli errori sono per gli utenti, mentre le eccezioni sono per gli sviluppatori.

Parliamone!

Raccontaci il tuo progetto e ti risponderemo al più presto.