Fun() met language-ext

Barend Techniek

Wanneer je in een nieuw projectteam start, kom je geheid technieken tegen die je nog niet kende. Of nog niet zelf had toegepast. In mijn huidige team werken we op basis van dotnet core en azure met onder andere repository-pattern, mediator-pattern (MediatR), CQRS en language-ext en met laatstgenoemde heb ik een interessante les geleerd over het consequent afhandelen van foutsituaties.

Language-ext is een library die een aantal functionele-taal-truken toevoegt aan C#. In ons project gebruiken we het vooral om error-afhandeling explicieter te maken. Of misschien beter gezegd: het afhandelen van in de applicatie voorziene uitzonderingen, want voor echt onverwachte fouten zullen er nog steeds exceptions worden gegooid. In het begin was het even flink zweten om mijn code goed (of althans compileerbaar) te krijgen, maar ik kan het inmiddels echt waarderen dat language-ext mij elke dag opnieuw dwingt om over het afhandelen van uitzonderingen na te denken.

In deze blog wil ik niet proberen om de denkwijze van language-ext volledig uit te leggen. Ik ga vooral in op het gebruik van zogenaamde Either<> constructies, in de hoop je net genoeg te vertellen om je enthousiast te maken. Wil je na het lezen meer weten? Kom dan lekker bij ons werken haha!

[HttpGet]
[Authorize(Policy = AdminOnly)]
[Route("RocketDetail/{rocketId:guid}")]
public async Task<IActionResult> RocketDetail(Guid rocketId)
{
    try
    {
        return Json(
            await _mediator.Send(new GetRocketDetail(rocketId)));
    }
    catch (Exception ex)
    {
        Log.Error(ex, "whoops");
        return BadRequest(ex.Message);
    }
}

Je ziet hier een typische controller action. Met een try/catch voor de situatie dat de action om welke reden dan ook mislukt. Met language-ext zou je ditzelfde als volgt kunnen implementeren:

[HttpGet]
[Authorize(Policy = AdminOnly)]
[Route("RocketDetail/{rocketId:guid}")]
public async Task<IActionResult> RocketDetail(Guid rocketId)
{
    return await _mediator
        .Send(new GetRocketDetail(new RocketId(rocketId))).ToAsync()
        .Match<ActionResult>(
            Left: e =>
            {
                Log.Error(e.Message);
                return new BadRequestError(new Error($"Could not get rocket detail for id {rocketId}"));
            },
            Right: Json);
}

Dit ziet er in eerste instantie vooral complexer uit. Wat hier gebeurt, is dat de GetRocketDetail niet gewoon een dto teruggeeft, maar een Either<Error, dto>. Either<TLeft,TRight> is een class van language-ext waarmee je de mogelijkheid krijgt om, afhankelijk van de runtime-situatie, twee verschillende soorten waarden terug te geven. Een typische truuk is dan om met Either ofwel de verwachte waarde te retourneren, ofwel een error-object. Dus in plaats van dat er een Exception moet worden gegooid (of erger: gewoon null retourneren). Vervolgens handel je met Match() expliciet de Left (een voorziene fout) en Right (een verwachte waarde) af.

De grote winst van deze werkwijze: het afhandelen van de exception is iets wat je zou kunnen vergeten en dan werkt de code ‘meestal’ goed. Maar wanneer je in de controller (of ergens anders in de code) een Either<> voor je kiezen krijgt, moet je bewust iets programmeren dat zowel de fout-flow (de ‘Left’) als de normale flow (de ‘Right’) afhandelt.

Om het voorbeeld verder toe te lichten: de GetRocketDetailHandler zou bijvoorbeeld dit kunnen doen:

public async Task<Either<IError, RocketDetailDto>> Handle(
    GetRocketDetail request,
    CancellationToken cancellationToken)
{
    return await _rocketReadRepository.Get(request.RocketId).ToAsync()
        .Map(RocketDetailDto.FromRead).ToEither();
}

Wat hier gebeurt, is dat de read-repository een Task<Either<IError, RocketRead>> teruggeeft. Dus de repository gebruikt ook language-ext en kan op die manier een error teruggeven. De ToAsync() is nodig om tijdelijk de Task<> weg te moffelen. De Map() doet iets slims: deze mapped het resultaat alleen als de repository een RocketRead opleverde en niet als de repository een IError teruggaf. Tenslotte tovert de ToEither() de Task<> weer terug, zodat het geheel kan worden ge-await. De ToAsync(), ToEither() en Map() zijn enkele veelgebruikte voorbeelden uit de language-ext library.

Anders gezegd: in de happy-flow wordt hier een read-model gemapped naar een dto, en in de unhappy-flow wordt de error van de repository doorgeven naar de controller. Vergelijkbaar met exception-handling, kan de code er voor kiezen of ter plaatse de error wordt afgehandeld, of dat je de error doorgeeft naar de aanroeper, zodat die code de error -bewust- afhandelt.

De constructie ziet er misschien eigenaardig uit maar nog niet per se super ingewikkeld. In de praktijk blijkt het gebruiken van language-ext echter een stevige leercurve te hebben en het kost een nieuwe collega al gauw weken of maanden om het goed onder de knie te krijgen. Bekijk het volgende voorbeeld maar eens:

return await (
    from user in _userService.GetCurrentUser().ToAsync()
    from allRocketVersions in _unitOfWork.RocketVersionWriteRepository.UpdateDetails(
        command.Command.RocketVersionId,
        command.Command.RocketName,
        command.Command.GroupId,
        command.Command.SubGroupId,
        user.Id).ToAsync()
    from fuelState in _unitOfWork.RocketFuelWriteRepository.Update(
        allRocketVersions.Last(),
        command.Command.FuelState).ToAsync()
    select rocketVersions)
    .Bind(rvList => rvList.Map(ConvertVersion).Sequence())
    .Map(RocketVersionMapper.Map)
    .ToEither();

Het zal je opvallen dat alle code aan elkaar is geknoopt. Dat is typerend voor functional programming en language-ext biedt je hier veel bouwstenen voor: elk “from” stukje levert een Either<> en deze vormen samen een cascade. Het probleem dat je hier impliciet mee oplost, is dat je geen wirwar van if/else constructies nodig hebt en vooral ook geen null-checks. De afspraak is dat elke aangeroepen method netjes een valide object teruggeeft, of een error-object, en dus nooit een null en ook geen Exception. Language-ext zorgt er voor dat in de happy-flow alle code wordt uitgevoerd. Maar als één van de calls een error oplevert, dan wordt die error meteen teruggegeven aan de caller en wordt de rest van de code dus niet uitgevoerd.

Omdat language-ext veel generics gebruikt en extension methods en ook nog veel impliciete casts, raak je soms helemaal de weg kwijt in wat je nu precies aan het mappen of binden bent. En vooral: waarom je code maar niet wil compilen. De compiler (of IDE-) errors zijn meestal ook niet heel behulpzaam, je moet het vaak doen met een melding als deze:

The type arguments for method 'EitherAsync<L,Ret> LanguageExt.EitherAsync<L,R>.Bind<Ret>(Func<R,EitherAsync<L,Ret>>)' cannot be inferred from the usage. Try specifying the type arguments explicitly.

Tot slot

Ik hoop dat het lezen van bovenstaande een klein beetje helpt wanneer je zelf in een project terecht komt waar language-ext wordt gebruikt. Of wanneer je besluit om het zelf op te tuigen. Buiten de documentatie op hun eigen github, had ik zelf ook veel aan deze tutorial: link.

Happy language-ext-ing!

Meer technische posts van onze hand...

Een afspraak maken bij ons op kantoor of wil je even iemand spreken? Stuur ons een mail of bel met Jolanda.