Gérer les cas particuliers avec OneOf

En C#, j'ai longtemps été insatisfait par la manière de gérer les cas particuliers. Notament, quand il faut remonter l'information jusqu'à l'utilisateur. J'ai essayé différentes techniques, mais je ne trouvé aucune solution satisfaisante... Jusqu'au jour où j'ai découvert la librairie OneOf.

???
Petit ruisseau en montagne

Le problème

En tant que développeur, nous devons en permanence gérer des cas particuliers dans notre code. C'est-à-dire des cas anormal, mais fessant partie des règles métiers. Par exemple, si on demande à un utilisateur de saisir un nombre entier, il faut gérer le cas particulier où l'utilisateur a saisi autre chose qu'un nombre entier. Dans l'exemple précédent, gérer signifie qu'il faut contrôler la saisie et avertir l'utilisateur de son erreur pour qu'il puisse rectifier sa saisi.

Pour la démonstration, considérons l'exemple suivant, une méthode qui doit créer un produit. Elle a en paramètre le code et le libellé du produit a créé. Si le code est invalide ou si le code est déjà utilisé par un autre produit, alors le produit ne peut être créé. Comment indiquer que l'opération ne s'est pas correctement déroulée?

La méthode peut retourner un booléen pour indiquer si l'opération a réussi ou échoué :

public class ProductsService
{
    public bool CreateProduct(string code, string label)
    {
        if (!CheckCodeIsValid(code) || !CheckCodeIsAvailable(code))
            return false;

        CreateProductImpl(code, label);
        return true;
    }
    ...
}

Seulement en cas d'échec, la cause de l'erreur ne sera pas disponible. Donc, il ne sera pas possible d'indiquer à l'utilisateur comment corriger sa saisie. L'utilisateur verra uniquement un message générique indiquant que l'opération a échoué, ce qui frustrant :

public class ProductsController : ControllerBase
{
    [HttpPost]
    public IActionResult CreateProduct(
        [FromServices]ProductsService service,
        string code,
        string label)
    {
        if (service.CreateProduct(code, label))
            return Ok();
        else
            return BadRequest();
    }
}

Avec les exceptions

Exception est synonyme de particulier, donc il semble instinctif d'utiliser les exceptions pour gérer ces cas particuliers. Pour chaque erreur, il est possible de lever une exception spécifique :

public class ProductsService : ProductsServiceBase
{
    public class InvalidCodeException : Exception { }
    public class CodeAlreadyUsedException : Exception { }
    public void CreateProduct(string code, string label)
    {
        if (!CheckCodeIsValid(code))
            throw new InvalidCodeException();
        if (!CheckCodeIsAvailable(code))
            throw new CodeAlreadyUsedException();

        CreateProductImpl(code, label);
    }
}

Ainsi, il est possible de traiter chaque type d'exception pour indiquer à l'utilisateur la cause de l'erreur :

public class ProductsController : ControllerBase
{
    [HttpPost]
    public IActionResult CreateProduct(
        [FromServices] ProductsService service,
        string code,
        string label)
    {
        try
        {
            service.CreateProduct(code, label);
            return Ok();
        }
        catch (ProductsService.InvalidCodeException)
        {
            ModelState.AddModelError(nameof(code), "`code` is invalid.");
            return ValidationProblem();
        }
        catch (ProductsService.CodeAlreadyUsedException)
        {
            ModelState.AddModelError(nameof(code), "`code` is already used.");
            return ValidationProblem();
        }
    }
}

Mais cette approche est peu recommandée. L'article suivant synthétise admirablement les reproche à cette technique :

Dont Use Exceptions For Flow Control

Pour ma part, ma principale critique est que les exceptions ne font pas parti de la signature de la méthode (pour la majorité des langages). De ce fait, les outils d'analyse ne peuvent pas correctement aider le développeur. Notamment en l'avertissant des cas qui ne sont pas correctement traités.

Avec le typage

J'ai eu l'occasion de travailler avec une application où cette complexité était traitée de manière originale. Certaines méthodes renvoyées un type de base Result :

public class ProductsService : ProductsServiceBase
{
    public abstract class Result { }
    public class Success : Result { }
    public class InvalidCode : Result { }
    public class CodeAlreadyUsed : Result { }

    public Result CreateProduct(string code, string label)
    {
        if (!CheckCodeIsValid(code))
            return new InvalidCode();
        if (!CheckCodeIsAvailable(code))
            return new CodeAlreadyUsed();

        CreateProductImpl(code, label);
        return new Success();
    }
}

L'appelant devait ensuite vérifier le type réel du retour pour traiter le résultat :

public class ProductsController : ControllerBase
{
    [HttpPost]
    public IActionResult CreateProduct(
        [FromServices] ProductsService service,
        string code,
        string label)
    {
        var result = service.CreateProduct(code, label);
        switch (result)
        {
            case ProductsService.Success:
                return Ok();
            case ProductsService.InvalidCode:
                ModelState.AddModelError(nameof(code), "`code` is invalid.");
                return ValidationProblem();
            case ProductsService.CodeAlreadyUsed:
                ModelState.AddModelError(nameof(code), "`code` is already used.");
                return ValidationProblem();
            default:
                throw new InvalidOperationException(
                    $"Unexpected result from {nameof(ProductsService)}.{nameof(ProductsService.CreateProduct)}."
                );
        }
    }
}

Ma principale critique avec cette technique, c'est que les différentes possibilités de retour ne sont pas explicitement définis. De ce fait, les outils d'analyse ne peuvent pas correctement aider le développeur. Notamment en lui indiquant que tous les cas ne sont pas correctement traités.

Bien qu'on évite certains écueils des exceptions, cela n'est toujours pas satisfaisant.

Pour cet exemple, il aurait été possible d'utiliser une énumération. Le code serait similaire, ainsi que les critiques formulées. Mais généralement, on a besoin de retourner des informations complémentaires avec l'erreur, nécessitant d'utiliser des classes.

Avec OneOf

OneOf est une librairie .NET amenant le type OneOf. Ce type permet d'encapsuler une valeur et de définir les différents types possibles pour cette valeur. Ainsi, en retournant une instance de OneOf, on retourne une valeur qui peut être de plusieurs types explicitement définis :

public class ProductsService
{
    public struct Success { }
    public struct InvalidCode { }
    public struct CodeAlreadyUsed { }
    public OneOf<Success, InvalidCode, CodeAlreadyUsed>
            CreateProduct(string code, string label)
    {
        if (!CheckCodeIsValid(code))
            return new InvalidCode();
        if (!CheckCodeIsAvailable(code))
            return new CodeAlreadyUsed();

        CreateProductImpl(code, label);
        return new Success();
    }
    ...
}

Ainsi, l'appelant doit explicitement traiter chaque type de résultat possible :

public class ProductsController : ControllerBase
{
    [HttpPost]
    public IActionResult CreateProduct(
        [FromServices]ProductsService service,
        string code,
        string label)
    {
        var result = service.CreateProduct(code, label);
        return result.Match<IActionResult>(
            success => Ok(),
            invalidCode => {
                ModelState(nameof(code), "`code` is invalid.");
                return ValidationProblem();
            },
            codeAlreadyUsed => {
                ModelState(nameof(code), "`code` is already used.");
                return ValidationProblem();
            }
        );
    }
}

De même, comme la signature de la méthode est explicite, il est facile de revenir sur un code utilisant OneOf, même après plusieurs mois.

Ainsi , si un cas n'est pas traité, le code ne compile pas. De même, si on ajoute un nouveau cas, alors on aura la garantie par le compilateur que ce nouveau cas est correctement traité. Cela facilite les évolutions.

Par contre, je ne trouve pas le code correspondant élégant, mais c'est une l'approche la plus satisfaisante à ce jour que je connaisse.

It's like a compile time checked switch statement!