Result Pattern in C#: gestire gli esiti previsti di un’operazione

Rappresentare successo e fallimento con Result in C#: un esempio con MediatR, la risposta HTTP e i casi in cui usare le eccezioni.

Un'attività da aggiornare può non esistere più. Il chiamante deve saperlo e decidere come rispondere. Con il Result Pattern, il metodo restituisce un valore che rappresenta il successo oppure un errore previsto. Questo rende gli esiti visibili nel contratto del metodo e permette di gestirli nel normale flusso del programma.

Rappresentare successo e fallimento

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

Il codice sotto è uno schema didattico, da completare per il proprio progetto. Prima di mantenere un tipo Result personalizzato, puoi confrontare le API di queste librerie: propongono modi diversi per rappresentare risultati ed errori.

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}";
}

Nell'esempio seguente il risultato viene restituito da un handler che usa il Mediator pattern per gestire la richiesta. Il Result Pattern si può usare anche in un servizio chiamato direttamente, senza MediatR.

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. L'assenza dell'attività viene quindi gestita come esito previsto. Le chiamate al repository possono comunque generare eccezioni.

Notare che non serve istanziare il tipo di ritorno con Result<ToDoItem>.Failure o Result<ToDoItem>.Success: le conversioni implicite definite sopra costruiscono il risultato. Le factory esplicite restano utili se vuoi rendere il passaggio più evidente a chi legge.

Dove gestire il risultato

Il servizio o l'handler restituisce l'esito applicativo; il controller lo traduce in una risposta HTTP. L'esempio usa 200 per il successo e 400 per ogni fallimento. In un'API reale, distingui i casi: una risorsa assente, un dato non valido e un conflitto non richiedono necessariamente lo stesso codice di risposta.

// ...
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);
        }
    }
}

Controlla IsSuccess prima di leggere il valore. In caso di fallimento, usa il codice dell'errore per scegliere la risposta e un messaggio adatto all'utente.

Result o Exception? Quando usare cosa

Il Result Pattern è utile per i fallimenti previsti dal contratto: login con password sbagliata, item inesistente, validazione fallita. Sono casi attesi, parte del normale ciclo della tua applicazione.

Le eccezioni restano adatte quando un'operazione non può completarsi secondo il proprio contratto. Un file mancante, per esempio, può essere un esito atteso durante una ricerca oppure un errore di configurazione all'avvio. Conta il contesto in cui viene usato il metodo.

Evita di convertire ogni eccezione in un risultato generico: perderesti informazioni utili alla diagnosi. Le linee guida .NET sulle eccezioni aiutano a distinguere il recupero da un errore dalla semplice intercettazione.

Da dove partire

Scegli un'operazione con esiti attesi ben definiti e rendili espliciti nel tipo di ritorno. Controlla come vengono gestiti da tutti i chiamanti prima di estendere il pattern al resto dell'applicazione.