Functional DDD with C# Part 3: the elevated world

Functional DDD with C# Part 3: the elevated world

Introduction

In the two previous articles, we talk about the fundamentals of functional programming and functional architecture. But before moving to functional DDD, we need to talk about another fundamental of functional programming: the elevated world.

What is the elevated world?

In functional programming, the elevated world is a world in which objects are wrapped in a container Elevated<T> or, more concisely, E<T>. You unknowingly use the elevated world when you deal with asynchronous programming: Task<T>.

So we can write:

  • int -> E<int>

  • string -> E<string>

And so on.

What is it for?

The elevated world has a lot of interests. The main one is that it makes it easier to chain functions.

Chaining functions bring out what software does: a succession of operations that transforms data into other data. Halas, too often, we first think about implementation details instead of thinking about a flow: structures, classes, interfaces, inheritance, algorithms, data tables and so many other details in which we drown.

Let's see how the elevated world helps us with functions chaining with a LINQ example. The following code gets the average of the 5 best scores. Look how comprehensive the program is using function chaining:

var scores = new List<int> { 12, 18, 17, 14, 13, 20, 8, 9, 14 };

var averageOf5BestScores = scores.OrderByDescending(x => x)
                                    .Take(5)
                                    .Average();

Console.WriteLine(averageOf5BestScores); //16.6

This chaining works because each function returns a value in the elevated world of enumerable integers: IEnumerable<int>.

Writing functions that chain well

Writing a function that chains well requires 4 criteria:

  • It must be pure. If it produces side effects, it will be complicated to chain other functions;

  • It must take an input parameter in the elevated world, for example, IEnumerable<T>;

  • It must be defined as an extension method of the input value (this IEnumerable<T>);

  • And it must return a result in an elevated world (IEnumerable<T>).

Let's take an example using the previous example:

  • I define a method TakeTopValuesOrderedByDescending() that extends IEnumerable<T>. It has no side effects.

  • It returns an IEnumerable<T> to chain calls of other functions.

public static class IEnumerableExtensions
{
    public static IEnumerable<T> TakeTopValuesOrderedByDescending<T>(this IEnumerable<T> values, int top) 
    {
        return values
                .OrderByDescending(m => m)
                .Take(top);
    }
}

Now I can rewrite the very first example by replacing the calls to OrderByDescending() and Take() with TakeTopValuesSortedByDescending():

var scores = new List<int> { 12, 18, 17, 14, 13, 20, 8, 9, 14 };

var averageOf5BestScores = scores
                            .TakeTopValuesOrderedByDescending(5)
                            .Average();

Console.WriteLine(averageOf5BestScores); //16.6

Working with the normal and the elevated world

During your work, you will have to jump from the normal world to the elevated world and vice-versa.

This is done by 3 types of operations:

  • An operation called Return in functional programming, which raises a value from the normal world to the elevated world;

  • An operation called Match to move back from the elevated world to the normal world and get the embedded value;

  • An operation to chain function calls in the elevated world: Bind, Map or Apply.

Writing our elevated world: Option<T>

Let's take an example and let's write our first elevated world: Option<T>. It is a very useful and very well-known pattern embedded in F# to model the absence of data (instead of using null which is a terrible idea).

A first definition

Option<T> has two possible values: Some and None. Here is its first definition:

public record Option<T>
{
    public readonly bool IsSome;

    private readonly T? _value;

    //a private constructor to force the use of static constructors (see below)
    private Option(T v, bool isSome) => (_value, IsSome) = (v, isSome);
}

Next, we need to write the 3 basic operations described before: elevating to Option<T>, returning from Option<T> and getting its inner value, and a chaining-functions helper Bind() or Map().

Operation 1: elevating a value to Option<T>

To elevate a value t to Option<T>, we will write two functions: Some(T t) and None().

First, we add two static constructors:

internal static Option<T> CreateSome(T v) => new(v, true);
internal static Option<T> CreateNone() => new(default!, false);

But they are not practical to use (Option.CreateSome(value)), that is why I keep them internal. I want to write Some(value). So let's do a small helper static class:

public static class Option
{  
    public static Option<T> Some<T>(T value) => Option<T>.CreateSome(value);

    public static Option<T> None<T>() => Option<T>.CreateNone();
}

Now we have two "Return" functions to elevate a value to Option<T>. Here is how to use them:

//define some value
Option<string> someString = Some("foo");
//define the absence of value, instead of using null
Option<string> none = None<string>();

Operation 2: returning to the normal world

Next, we need to move back to the normal world and read the inner value of Option<T>. We will use a Match function. It unwraps the inner value of Option<T> and fires 2 functions that are set as parameters: one if the value is defined and the other one if it is not.

public R Match<R>(Func<R> None, Func<T, R> Some) => IsSome ? Some(_value!) : None();

Here is how to use it. Important: the returned value of provided functions for Some and None cases must be of the same type (string in this example).

public static string GetOptionValue<T>(Option<T> value)
{
    return value.Match(
            None: () => "Empty",
            Some: (value) => $"{value}");
}

Operation 3: chaining functions in the elevated world

We need a generic mechanism to chain operations in the elevated world. In the world of Option<T>, chaining operations must be done only if the inner value of Option<T> is defined. This will be the purpose of the Bind() function.

It takes 2 parameters :

  • A value in the elevated world (Option<T> in our example);

  • And a function that operates in the normal world T and returns another value in the world of Option. The returned value wrapped in Option<> can be of another type (R for "result type"): the signature of this function is therefore T => Option<R>.

So here is the definition of Bind() for Option<T>:

public static Option<R> Bind<T, R>(this Option<T> option, Func<T, Option<R>> func)
{
    return option.Match(
                Some: v => func(v),
                None: () => None<R>());
}

What we see is that Bind applies func on the inner value of option only if it exists. Else, the result will be None<R>.

Let's give it a try :

[TestMethod]
public void When_binding_a_AddOne_function_on_some_zero_value_then_the_result_is_one()
{
    var addOne = (int i) => Some(i + 1);
    var sut = Some(0);

    var result = sut.Bind(addOne)
                    .Match(
                            Some: v => v,
                            None: () => 0);

    Assert.AreEqual(1, result);
}

Now let's test the chaining:

[TestMethod]
public void When_binding_three_AddOne_functions_on_some_zero_value_then_the_result_is_three()
{
    var addOne = (int i) => Some(i + 1);
    var sut = Some(0);

    var result = sut.Bind(addOne)
                    .Bind(addOne)
                    .Bind(addOne)
                    .Match(
                            Some: v => v,
                            None: () => 0);

    Assert.AreEqual(3, result);
}

But what if one of the chained functions returns a None because something wrong happened? Well, the final result of the chain will be a None because Bind applies the parameterized function only if the value of Option<T> is Some.

[TestMethod]
public void When_binding_a_function_that_returns_none_then_the_result_is_none()
{
    var addOne = (int i) => Some(i + 1);
    var funcNone = (int i) => None<int>();
    var sut = Some(0);

    var result = sut.Bind(addOne)
                    .Bind(funcNone)
                    .Bind(addOne);

    Assert.IsFalse(result.IsSome);
}

What is great is that Bind uses a "railway" management of problems: if one function in the chain returns a None, then the final result will be None. This mechanism is extremely well explained by Scott Wlaschin here. Managing errors in the domain become then very easy because you don't have to write any conditional logic. We will use it a lot in the application layer of our functional architecture.

What about Map()?

Map() is the same than Bind() instead that it takes as a parameter a function that returns a value in the normal world. As a result, it needs to lift it the elevated world.

Map() for Option<T> looks like this:

public static Option<R> Map<T, R>(this Option<T> option, Func<T, R> func)
{
    return option.Match(
             Some: v => Some(func(v)), // Lifting func's result in the elevated world
             None: () => None<R>());
}

Stick to the elevated world as long as you can

Given the power of the Bind and Map to chain functions and their efficient way of handling errors, I highly recommend sticking to the elevated world for as long as you can.

Get the code

You can get Option<T> and its unit tests in my Grenat.Functional.DDD library here.

Summary

  • The elevated world makes it easier to write functions that can be chained together.

  • 3 types of operations allow you to work between the elevated world and the real world:

    • A Return operation (Some and None in this example) to raise a value from the normal world to the elevated world.

    • A Match operation to return to the normal world.

    • Operations to chain functions in the elevated world: Bind, Map, Apply.

  • Bind and Map are great to manage errors without having to write conditional logic.

  • Stay in the elevated world as long as you can to benefit from the power of Bind and Map.

About the cover image

Light pillars on the city of Kiruna in Swedish Lapland. This phenomenon of the polar regions is caused by the reflection of the intense light sources of the cities in the millions of ambient air crystals.

See it in large size on my portfolio.