Refactoring a ReactiveUI .Net MAUI app to a more functional approach

Adopting a Functional Programming style

Introduction

Combining Functional and Reactive Programming allows us to benefit from both styles. In a .NET MAUI app using C#, we can achieve this by using the ReactiveUI and Language-Ext libraries alongside the built-in language support for functional idioms, such as immutability through the record type and read-only collections.

Code written in a Reactive Programming style is declarative and operates on streams of data and events, making it easier to understand. The Functional Programming style with Monads enables a functional approach to error handling, facilitating a clearer understanding of the application flow. Using types and collections that support immutability alongside pure, side-effect-free functions enhances code predictability, reduces error susceptibility, and improves concurrency suitability. As a result, this leads to a cleaner and more modular app design, allowing for more efficient addition of new features.

This article refactors the People In Space MAUI demo app we built in the article Using ReactiveUI with .Net MAUI to create offline-capable apps to adopt a more functional style while retaining the Reactive Programming benefits.

Demo App

John O’Reilly’s People In Space Kotlin Multiplatform demo app inspired the creation of a demo app that displays a list of astronauts and shows a detailed view when tapped. You can find the refactored functional version in this GitHub repo.

The significant change from the original demo app is to use the language-ext library from which we use-

  • Option Monad – A monad holding a Success value or a None (no value)
  • Either Monad – A monad holding a Left and Right value, where the Left value represents the error

The changes made in this refactoring generally make types and collections immutable using record types and ReadOnlyLists.

The ‘Either’ monad is used to adopt a functional approach to error handling with error types defined as records.  Scott Wlaschin coined the term ‘Railway Orientated Programming’ to describe this approach to error handling flow; while his articles are written in F#, they are very readable and pertinent. A post Against Railway-Orientated Programming describes when not to use this approach.

The ‘Option’ monad is used in the Detail Page View when parsing the navigation parameters.

Note there is some setup required see Getting started, I actually created a LanguageExt.cs file and setup global using statements:

global using LanguageExt;
global using LanguageExt.Common;
global using static LanguageExt.Prelude;
global using LanguageExt.Effects;
global using LanguageExt.Pipes;
global using LanguageExt.Pretty;

The alternative is to add the using statements to each file as required:

using LanguageExt;
using static LanguageExt.Prelude;

Why Functional Programming?

Functional programming (FP) is a paradigm that treats computation as evaluating mathematical functions and avoids changing-state and mutable data. It has recently gained traction due to its emphasis on immutability, first-class, and pure functions.

I found Functional Programming in C# – A Brief Consideration to be a helpful article showing some practical examples.

Design Overview

The app employs the MVVM design pattern within a Shell. Its main page displays a list of astronauts and allows users to navigate to a detail view when an astronaut is tapped.

A repository uses Akavache to cache the data in an SQLite database, which is loaded when the main view is displayed or a pull-to-refresh is initiated.

We monitor the network connection status and notify the user of any changes in the connection status.

Functional Refactoring Walkthrough

We refactored the CrewModel from a class to a record type available from C# 10. Records are immutable by default and hence safer, preventing unintended modification. They are intended for storing data, so they offer a concise syntax and support value-based equality

The original class:

public partial class CrewModel
{
    [JsonProperty("name")]
    public string Name { get; set; }

    [JsonProperty("agency")]
    public string Agency { get; set; }

    [JsonProperty("image")]
    public Uri Image { get; set; }

    [JsonProperty("wikipedia")]
    public Uri Wikipedia { get; set; }

    [JsonProperty("launches")]
    public string[] Launches { get; set; }

    [JsonProperty("status")]
    public Status Status { get; set; }

    [JsonProperty("id")]
    public string Id { get; set; }
}

public enum Status { Active, Inactive, Retired, Unknown };

public partial class CrewModel
{
    public static CrewModel[] FromJson(string json) => JsonConvert.DeserializeObject<CrewModel[]>(json, Converter.Settings);
}

The refactored CrewModel also uses the Either monad to introduce a functional error-handling approach, explicitly returning either a successful result or an error. I have created specific error types and handled these differently in the app.

However, you could continue using Exceptions if, for example, you wish to keep the stack trace information and don’t do anything differently for the different types of errors.

An IReadOnlyList is used rather than a string array, which is immutable.

public record CrewModel(
    [property: JsonProperty("name")] string Name,
    [property: JsonProperty("agency")] string Agency,
    [property: JsonProperty("image")] Uri Image,
    [property: JsonProperty("wikipedia")] Uri Wikipedia,
    [property: JsonProperty("launches")] IReadOnlyList Launches,
    [property: JsonProperty("status")] Status Status,
    [property: JsonProperty("id")] string Id)
{
    public static Either<CrewError, CrewModel[]> FromJson(string json)
    {
        try
        {
            var models = JsonConvert.DeserializeObject<CrewModel[]>(json, Converter.Settings);
            
            return models == null 
                ? Either<CrewError, CrewModel[]>.Left(new ParsingError("Deserialization returned null.")) 
                : Either<CrewError, CrewModel[]>.Right(models);
        }
        catch (JsonException ex)
        {
            return Either<CrewError, CrewModel[]>.Left(new ParsingError("Failed to parse crew data: " + ex.Message));
        }
    }
}

The definition of the CrewError types also use the record type:

public abstract record CrewError(string Message);

public record NetworkError(string Message) : CrewError(Message);

public record ParsingError(string Message) : CrewError(Message);

public record CacheError(string Message) : CrewError(Message);

The StatusConverters ReadJson is also refactored, the original:

public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer)
    {
        if (reader.TokenType == JsonToken.Null) return null;
        var value = serializer.Deserialize(reader);
        if (value == "active")
        {
            return Status.Active;
        }
        throw new Exception("Cannot unmarshal type Status");
    }

The refactored version uses pattern matching, covering all possible values with a clear error in the event we cannot deserialise the value:

public override object? ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer)
    {
        if (reader.TokenType == JsonToken.Null) return null;
        var value = serializer.Deserialize<string>(reader);
        return value switch
        {
            "active" => Status.Active,
            "inactive" => Status.Inactive,
            "retired" => Status.Retired,
            "unknown" => Status.Unknown,
            _ => throw new JsonException("Cannot unmarshal type Status")
        };
    }

The CrewRepository is refactored to use Either to use functional error handling and to use an immutable IReadOnlyList, the interface was:

public interface ICrewRepository
{
    bool IsBusy { get; set; }
    IObservable<ICollection<CrewModel>?> GetCrew(bool forceRefresh = false);
}

And the refactored version:

public interface ICrewRepository
{
    bool IsBusy { get; set; }
    IObservable<Either<CrewError, IReadOnlyList<CrewModel>>> GetCrew(bool forceRefresh = false);
}

The CrewRepository was originally:

public class CrewRepository(
    ISchedulerProvider schedulerProvider,
    ISpaceXApi spaceXApi,
    IBlobCache cache)
    : ReactiveObject, ICrewRepository
{
    private const string CrewCacheKey = "crew_cache_key";
    private readonly TimeSpan _cacheLifetime = TimeSpan.FromDays(1);

    [Reactive]
    public bool IsBusy { get; set; }

    public IObservable<ICollection?> GetCrew(bool forceRefresh = false)
    {
        return Observable.Defer(() =>
        {
            IsBusy = true;
            if (forceRefresh)
            {
                return FetchAndCacheCrew();
            }

            DateTimeOffset? expiration = DateTimeOffset.Now + _cacheLifetime;
            return cache.GetOrFetchObject(CrewCacheKey,
                    fetchFunc: FetchAndCacheCrew,
                    absoluteExpiration: expiration)
                .Do(_ => IsBusy = false);
        }).SubscribeOn(schedulerProvider.ThreadPool);
    }

    private IObservable<ICollection> FetchAndCacheCrew()
    {
        return Observable.Create<ICollection>(async observer =>
        {
            try
            {
                var crewJson = await spaceXApi.GetAllCrew().ConfigureAwait(false);
                var crew = CrewModel.FromJson(crewJson).ToList();
                await cache.InsertObject(CrewCacheKey, crew, DateTimeOffset.Now + _cacheLifetime);
                observer.OnNext(crew);
                observer.OnCompleted();
            }
            catch (Exception ex)
            {
                observer.OnError(ex);
            }
            finally
            {
                IsBusy = false;
            }
        }).SubscribeOn(schedulerProvider.ThreadPool);
    }
}

The refactored version also splits the cache and API fetch logic to try to make the code clearer and easier to understand/maintain. We use async/await in the FetchAndProcessCrew and use the specific NetworkError type in the event of an exception calling the API:

public class CrewRepository(ISchedulerProvider schedulerProvider, ISpaceXApi spaceXApi, IBlobCache cache)
    : ReactiveObject, ICrewRepository
{
    private const string CrewCacheKey = "crew_cache_key";
    private readonly TimeSpan _cacheLifetime = TimeSpan.FromDays(1);

    [Reactive] public bool IsBusy { get; set; }
    
    public IObservable<Either<CrewError, IReadOnlyList<CrewModel>>> GetCrew(bool forceRefresh = false)
    {
        return Observable.Defer(() =>
        {
            IsBusy = true;
            var fetchObservable = forceRefresh ? FetchAndCacheCrew() : FetchFromCacheOrApi();
            return fetchObservable.Do(_ => IsBusy = false);
        }).SubscribeOn(schedulerProvider.ThreadPool);
    }

    private IObservable<Either<CrewError, IReadOnlyList<CrewModel>>> FetchFromCacheOrApi()
    {
        DateTimeOffset? expiration = DateTimeOffset.Now + _cacheLifetime;
        return cache.GetOrFetchObject(CrewCacheKey,
                async () => await FetchAndProcessCrew(), expiration)
            .Catch((Exception ex) =>
                Observable.Return(Either<CrewError, IReadOnlyList<CrewModel>>.Left(new CacheError(ex.Message))));
    }

    private async Task<Either<CrewError, IReadOnlyList<CrewModel>>> FetchAndProcessCrew()
    {
        try
        {
            var crewJson = await spaceXApi.GetAllCrew().ConfigureAwait(false);
            var result = CrewModel.FromJson(crewJson);
            return result.Match(
                Right: crew => Right<CrewError, IReadOnlyList<CrewModel>>(crew.ToList().AsReadOnly()),
                Left: Left<CrewError, IReadOnlyList<CrewModel>>
            );
        }
        catch (HttpRequestException ex)
        {
            return Left<CrewError, IReadOnlyList<CrewModel>>(new NetworkError("Network error: " + ex.Message));
        }
        catch (Exception ex)
        {
            return Left<CrewError, IReadOnlyList<CrewModel>>(new CacheError("Unexpected error: " + ex.Message));
        }
    }

    private IObservable<Either<CrewError, IReadOnlyList<CrewModel>>> FetchAndCacheCrew()
    {
        return Observable.FromAsync(FetchAndProcessCrew)
            .Catch((Exception ex) =>
                Observable.Return(Either<CrewError, IReadOnlyList<CrewModel>>.Left(new NetworkError(ex.Message))))
            .SubscribeOn(schedulerProvider.ThreadPool);
    }
}

I updated the code on 27/06/24 to add a CrewModelComparer so the images don’t flash on pull to refresh.

The MainPageViewModel originally looked like this:

public class MainPageViewModel : ReactiveObject, IActivatableViewModel
{
    private readonly ISchedulerProvider _schedulerProvider;
    private readonly ICrewRepository _crewRepository;
    private readonly INavigationService _navigationService;
    private readonly IUserAlerts _userAlerts;
    
    [Reactive]
    public string PageTitle { get; set; }
    
    [ObservableAsProperty]
    public bool IsRefreshing { get; }
    
    public ReactiveCommand<bool, ICollection?> LoadCommand { get; }
    
    public ReactiveCommand<CrewModel, Unit> NavigateToDetailCommand { get; private set; }
    
    private ReadOnlyObservableCollection _crew;

    public ReadOnlyObservableCollection Crew
    {
        get => _crew;
        set => this.RaiseAndSetIfChanged(ref _crew, value);
    }

    private static readonly Func<CrewModel, string> KeySelector = crew => crew.Id;
    private readonly SourceCache<CrewModel, string> _crewCache = new(KeySelector);
        
    private static readonly CrewModelComparer CrewComparer = new();
    
    public ViewModelActivator Activator { get; } = new();
    
    public MainPageViewModel(ISchedulerProvider schedulerProvider,
        ICrewRepository crewRepository,
        INavigationService navigationService,
        IUserAlerts userAlerts)
    {
        _schedulerProvider = schedulerProvider;
        _crewRepository = crewRepository;
        _navigationService = navigationService;
        _userAlerts = userAlerts;
        
        PageTitle = "People In Space MAUI";
        
        var crewSort = SortExpressionComparer
            .Ascending(c => c.Name);

        var crewSubscription = _crewCache.Connect()
            .Sort(crewSort)
            .Bind(out _crew)
            .ObserveOn(_schedulerProvider.MainThread)        
            .DisposeMany()                              
            .Subscribe();
        
        LoadCommand = ReactiveCommand.CreateFromObservable<bool, ICollection?>(
            forceRefresh =>  _crewRepository.GetCrew(forceRefresh),
            this.WhenAnyValue(x => x.IsRefreshing).Select(x => !x), 
            outputScheduler: _schedulerProvider.MainThread); 
        LoadCommand.ThrownExceptions.Subscribe(Crew_OnError);
        LoadCommand.Subscribe(Crew_OnNext);
        
        NavigateToDetailCommand = ReactiveCommand.Create(NavigateToDetail);
        
        this.WhenActivated(disposables =>
        {
            this.WhenAnyValue(x => x._crewRepository.IsBusy)
                .ObserveOn(_schedulerProvider.MainThread)
                .ToPropertyEx(this, x => x.IsRefreshing, scheduler: _schedulerProvider.MainThread)
                .DisposeWith(disposables);
            
            disposables.Add(crewSubscription);
        });
    }
    
    private void Crew_OnNext(ICollection? crew)
    {
        try
        {
            if (crew != null) UpdateCrew(crew);
        }
        catch (Exception exception)
        {
            Crew_OnError(exception);
        }
    }
    
    private void UpdateCrew(ICollection crew)
    {
        _crewCache.Edit(innerCache =>
        {
            innerCache.AddOrUpdate(crew, CrewComparer);
        });
    }

    private void Crew_OnError(Exception e)
    {
        _userAlerts.ShowToast(e.Message).FireAndForgetSafeAsync();
    }
    
    private void NavigateToDetail(CrewModel crewMember)
    {
        var name = Uri.EscapeDataString(crewMember.Name);
        var image = Uri.EscapeDataString(crewMember.Image.ToString());
        var wikipedia = Uri.EscapeDataString(crewMember.Wikipedia.ToString());
        
        var route = $"{Routes.DetailPage}?name={name}&image={image}&wikipedia={wikipedia}";
        _navigationService.NavigateAsync(route);
    }
}
}

We have refactored the code to use the Either monad for error handling. We also set the IsRefreshing property from the ReactiveCommand’s IsBusy property to provide simpler code – previously used the IsBusy property of the CrewRepository.

We already used an immutable ReadOnlyObservableCollection; however, we now declare the property setter as private, promoting immutability.

We show the user a Toast if there is a NetworkError or CacheError since we assume this is a normal flow. However, in the event of a ParseError, we show a SnackBar asking for the user to contact support to upgrade the app since this is an error where the data returned from the API does not match our expectations.

public class MainPageViewModel : ReactiveObject, IActivatableViewModel
{
    private readonly ISchedulerProvider _schedulerProvider;
    private readonly ICrewRepository _crewRepository;
    private readonly INavigationService _navigationService;
    private readonly IUserAlerts _userAlerts;

    [Reactive]
    public string PageTitle { get; private set; } = "People In Space Functional MAUI";

    [ObservableAsProperty]
    public bool IsRefreshing { get; }

    public ReactiveCommand<bool, Either<CrewError, IReadOnlyList>> LoadCommand { get; }

    public ReactiveCommand<CrewModel, Unit> NavigateToDetailCommand { get; private set; }

    public ReadOnlyObservableCollection Crew { get; private set; }

    private readonly SourceCache<CrewModel, string> _crewCache = new(crew => crew.Id);

    private static readonly CrewModelComparer CrewComparer = new();
    
    public ViewModelActivator Activator { get; } = new();

    public MainPageViewModel(
        ISchedulerProvider schedulerProvider,
        ICrewRepository crewRepository,
        INavigationService navigationService,
        IUserAlerts userAlerts)
    {
        _schedulerProvider = schedulerProvider;
        _crewRepository = crewRepository;
        _navigationService = navigationService;
        _userAlerts = userAlerts;

        var crewSort = SortExpressionComparer.Ascending(c => c.Name);

        var crewSubscription = _crewCache.Connect()
            .Sort(crewSort)
            .Bind(out var crew)
            .ObserveOn(_schedulerProvider.MainThread)        
            .DisposeMany()                              
            .Subscribe();

        Crew = crew;

        LoadCommand = ReactiveCommand.CreateFromObservable<bool, Either<CrewError, IReadOnlyList>>(
            crewRepository.GetCrew,
            outputScheduler: _schedulerProvider.MainThread);

        LoadCommand.ThrownExceptions.Subscribe(ex => ShowError(ex.Message));
        LoadCommand.Subscribe(result => result.Match(
            Right: UpdateCrew,
            Left: HandleError));

        NavigateToDetailCommand = ReactiveCommand.Create(NavigateToDetail);

        this.WhenActivated(disposables =>
        {
            LoadCommand.IsExecuting.ToPropertyEx(
                    this,
                    x => x.IsRefreshing,
                    scheduler: _schedulerProvider.MainThread)
                .DisposeWith(disposables);
            
            disposables.Add(crewSubscription);
        });
    }

    private void UpdateCrew(IReadOnlyList crew)
    {
        _crewCache.Edit(cache => cache.AddOrUpdate(crew, CrewComparer));
    }

    private void HandleError(CrewError error)
    {
        switch (error)
        {
            case ParsingError parsingError:
                ShowSnackbar("Parsing error occurred. Please contact customer support or update the app.");
                break;
            default:
                ShowError(error.Message);
                break;
        }
    }

    private void ShowSnackbar(string message)
    {
        _userAlerts.ShowSnackbar(message, TimeSpan.FromSeconds(20)).FireAndForgetSafeAsync();
    }
    
    private void ShowError(string message)
    {
        _userAlerts.ShowToast(message).FireAndForgetSafeAsync();
    }

    private void NavigateToDetail(CrewModel crewMember)
    {
        var parameters = new Dictionary<string, string>
        {
            ["name"] = Uri.EscapeDataString(crewMember.Name),
            ["image"] = Uri.EscapeDataString(crewMember.Image.ToString()),
            ["wikipedia"] = Uri.EscapeDataString(crewMember.Wikipedia.ToString())
        };
        var route = $"{Routes.DetailPage}?{string.Join("&", parameters.Select(p => $"{p.Key}={p.Value}"))}";
        _navigationService.NavigateAsync(route);
    }
}

The DetailPageViewModel was originally:

public class DetailPageViewModel : ReactiveObject, IQueryAttributable
{
    [Reactive]
    public string PageTitle { get; set; } = "Biography";

    [Reactive] 
    public CrewModel? CrewMember { get; private set; }
    
    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        ArgumentNullException.ThrowIfNull(query);

        var name = GetQueryValue(query, "name");
        var image = GetQueryValue(query, "image");
        var wikipedia = GetQueryValue(query, "wikipedia");

        if (!string.IsNullOrEmpty(name) && Uri.TryCreate(image, UriKind.Absolute, out var imageUri) && Uri.TryCreate(wikipedia, UriKind.Absolute, out var wikiUri))
        {
            CrewMember = new CrewModel
            {
                Name = name,
                Image = imageUri,
                Wikipedia = wikiUri
            };
        }
        else
        {
            // Handle the case where one or more parameters are invalid
            throw new ArgumentException("Invalid or missing query parameters.");
        }
    }

    private static string GetQueryValue(IDictionary<string, object> query, string key)
    {
        if (query.TryGetValue(key, out var value) && value is string stringValue)
        {
            return Uri.UnescapeDataString(stringValue);
        }
        return string.Empty;
    }
}

We introduced a CrewDetailModel using a record type, which uses only a subset of the data in the CrewModel. This model is composed of the parsed query attributes using functional composition, so the code is more readily understandable.

public record CrewDetailModel(string Name, Uri Image, Uri Wikipedia);

We refactored to use an Option monad to handle missing or invalid values and use the Bind method to chain operations that may produce None. The refactored code:

public class DetailPageViewModel : ReactiveObject, IQueryAttributable
{
    [Reactive]
    public string PageTitle { get; set; } = "Biography";

    [Reactive] 
    public CrewDetailModel? CrewMember { get; private set; }

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        var crewMemberOption = ParseQueryAttributes(query);
        _ = crewMemberOption.Match(
            Some: crewMember => 
            {
                CrewMember = crewMember;
                return Unit.Default;
            },
            None: () => throw new ArgumentException("Invalid or missing query parameters.")
        );
    }

    private static Option ParseQueryAttributes(IDictionary<string, object> query)
    {
        var nameOption = GetQueryValue(query, "name");
        var imageOption = GetQueryValue(query, "image")
            .Bind(image => Uri.TryCreate(image, UriKind.Absolute, out var imageUri) ? Some(imageUri) : None);
        
        var wikipediaOption = GetQueryValue(query, "wikipedia")
            .Bind(wikipedia => Uri.TryCreate(wikipedia, UriKind.Absolute, out var wikipediaUri) ? Some(wikipediaUri) : None);

        return nameOption.Bind(name => imageOption.Bind(image => wikipediaOption.Map(wikipedia => new CrewDetailModel(name, image, wikipedia))));
    }

    private static Option GetQueryValue(IDictionary<string, object> query, string key)
    {
        return query.TryGetValue(key, out var value) && value is string stringValue
            ? Some(Uri.UnescapeDataString(stringValue))
            : None;
    }
}

Summary

Adopting a Functional Programming approach in your .NET MAUI applications can achieve greater code clarity, robustness, and maintainability. This refactoring exercise demonstrates the practical benefits of Functional Programming principles in enhancing our reactive application built with ReactiveUI and .NET MAUI.

As your codebase evolves, incorporating functional programming concepts can lead to a more scalable and resilient software architecture that adapts to future challenges and requirements.

If you are interested in discussing how we could help with .Net, Kotlin or KMP development, you can reach me, Richard Woollcott, at [email protected]