Oleksandr Zelinskyi
ELEKS
Oleksandr ZelinskyiMiddle Software Developer @ ELEKS

C# Pattern Matching: The Good and the Bad, Based on Case Studies

Discover how pattern matching in C# can simplify and organize your code through practical examples.
10.09.20245 min
C# Pattern Matching: The Good and the Bad, Based on Case Studies

In this article, you will read about case studies of a C# pattern matching technique that was applied in a real commercial project I worked on. No theoretical scenarios or “squares and rectangles” examples - just real-life case studies applied to a commercial product. By the end, you will have a better understanding of how pattern matching can help with refactoring conditionals and what you need to be aware of.

What is pattern matching?

“Pattern matching is a technique where you test an expression to determine if it has certain characteristics. C# pattern matching provides a more concise syntax for testing expressions and taking action when an expression matches.”
Source: https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/pattern-matching

The first version of pattern matching was introduced in C# 7 and has evolved in subsequent C# versions. Let’s look at the simplest example of the pattern matching technique:

string? message = GetMessageOrDefault();

if (message is not null)
{
    Console.WriteLine(message);
}

Below, you can find a more advanced example with pattern matching and relational patterns:

public static string GetEggHardness(int boilingTimeinMins)
{
    return boilingTimeInMins switch
    {
        >= 0 and <= 5 => "Soft",
        > 5 and <= 8 => "Medium",
        > 8 => "Hard",
        _ => throw new ArgumentException("Invalid boiling time")
    };
}

Recently, our project was upgraded to .NET 6.0, allowing us to start using pattern matching actively in our code. Below you will find some real-life cases that demonstrate that pattern matching is not as simple as the example above.

Example 1: Pattern matching for workflow management

After we started using pattern matching, the first challenge was handling approval workflow management. This occurs when an entity goes through different states, requiring various checks and approvals during its lifecycle in the system.

For example, an article created in a blog may start with the “Draft” status. Once the draft was completed, it moved to the editor review phase, changing its status to “Waiting for review”. Later in the process, when the manager reviews it, the status can change to “Waiting for author” if modifications are needed, or “Published” if the editor approves.

Illustration 1: blog post workflow

Here is how the code representing the status flow looked like with conditional statements:

public Func<ArticlePayload, Task> GetAction(MoveWorkflowPayload payload)
{
    if ((payload.OldStatus == null && payload.NewStatus == ApprovalStatuses.Draft)
        || (payload.OldStatus == ApprovalStatuses.Draft && payload.NewStatus == ApprovalStatuses.Draft))
    {
        return _moveToDraftAsync;
    }
    else if ((payload.OldStatus == null && payload.NewStatus == ApprovalStatuses.WaitingForReview)
        || (payload.OldStatus == ApprovalStatuses.WaitingForAuthor && payload.NewStatus == ApprovalStatuses.WaitingForReview)
        || (payload.OldStatus == ApprovalStatuses.Draft && payload.NewStatus == ApprovalStatuses.WaitingForReview))
    {
        return _moveToWaitingForReviewAsync;
    }
    else if (payload.OldStatus == ApprovalStatuses.WaitingForReview 
        && payload.NewStatus == ApprovalStatuses.WaitingForAuthor 
        && payload.IsManager)
    {
        return _moveToWaitingForAuthorAsync;
    }
    else if (payload.OldStatus == ApprovalStatuses.WaitingForReview 
        && payload.NewStatus == ApprovalStatuses.Published 
        && payload.IsManager)
    {
        return _moveToPublishedAsync;
    }
    else
    {
        return _defaultCase;
    }
}

There’s no doubt that it doesn’t look like a piece of art. Let’s see how it looks after applying pattern matching:

public Func<ArticlePayload, Task> GetAction(MoveWorkflowPayload payload)
{
    return payload switch
    {
        // Draft
        { OldStatus: null, NewStatus: ApprovalStatuses.Draft } => _moveToDraftAsync,
        { OldStatus: ApprovalStatuses.Draft, NewStatus: ApprovalStatuses.Draft } => _moveToDraftAsync,

        // Waiting for review
        { OldStatus: null, NewStatus: ApprovalStatuses.WaitingForReview } => _moveToWaitingForReviewAsync,
        { OldStatus: ApprovalStatuses.WaitingForAuthor, NewStatus: ApprovalStatuses.WaitingForReview } => _moveToWaitingForReviewAsync,
        { OldStatus: ApprovalStatuses.Draft, NewStatus: ApprovalStatuses.WaitingForReview } => _moveToWaitingForReviewAsync,

        // Waiting for author
        { OldStatus: ApprovalStatuses.WaitingForReview, NewStatus: ApprovalStatuses.WaitingForAuthor, IsManager: true } => _moveToWaitingForAuthorAsync,

        // Published
        { OldStatus: ApprovalStatuses.WaitingForReview, NewStatus: ApprovalStatuses.Published, IsManager: true } => _moveToPublishedAsync,

        // Default
        _ => _defaultCase,
    };
}

Now we can confidently say that it's much more readable and flexible. You can easily add new conditions and statuses without losing the readability.

Example 2: Looks crazy, but actually good

Additionally, we applied pattern matching in the code to build an email message based on the user's selections. After presenting the user with a popup displaying the initially selected usernames, each selection could be removed, and new ones could be added. This required the use of conditional statements to construct the e-mail body.

Illustration 2 – User selection popup

The code before the refactoring, built using if-else statements:

string message;

if (currentlySelectedUsers.Count() == 0)
{
    message = BuildNoUserSelectedMessage();
}
else if (addedUsers.Count > 0 && removedUsers.Count > 0)
{
    message = BuildUserAddedOrModifiedMessage();
}
else if (addedUsers.Count > 0 && removedUsers.Count <= 0)
{
    message = BuildAddedUsersMessage();
}
else if (addedUsers.Count <= 0 && removedUsers.Count > 0)
{
    message = BuildRemovedUsersMessage();
}
else
{
    throw new InvalidOperationException("Not supported combination");
}

This is what it looks like after applying pattern matching:

var message = (currentlySelectedUsers.Count, removedUsers.Count, currentlySelectedUsers.Count()) switch
{
    (_, _, 0) => BuildNoUserSelectedMessage (),
    ( > 0, > 0, _) => BuildUserAddedOrModifiedMessage (),
    ( > 0, <= 0, _) => BuildAddedUsersMessage (),
    ( <= 0, > 0, _) => BuildRemovedUsersMessage (),
    _ => throw new InvalidOperationException("Not supported combination")
};

Please note that by using underscores, properties not being used in a particular condition can be omitted.

When I saw this code for the first time I thought: Why do you write code with smiley faces?. But to be honest, it proved its point. Instead of having multiple, illegible if statements, it uses more readable and concise code. It takes up less space and is clearer overall.

However, be cautious when using too many properties for the comparison, as it can lead to code smells. With just a few input conditions, it's easy to remember the position of each input, but with more than 2 or 3, you might find yourself constantly moving up and down to check each condition’s position in the list.

Example 3: Do not overengineer

Below there is a new part of the code that was introduced after getting acquainted with 'pattern matching'. When I first saw this code snippet, it took me a few minutes to understand what was going on.

await(payload switch
{
    { IsUpdated: true, Status: not ApprovalStatus.Draft } => _randomHelper.UpdateUserAsync(
        new UserRequestPayload
        {
            UserTypeId = userType.UserTypeId,
            Data = userItemResponse.Data
        },
        payload, userType.Code),
    { IsUpdated: null or false, Status: not ApprovalStatus.Draft } => _randomHelper.RegisterUserAsync(payload, userType.Code),
    _ => Task.FromResult(payload)
});

And this is how it was refactored:

if (payload.Status == ApprovalStatus.Draft)
    return;

if (payload.IsUpdated.HasValue && payload.IsUpdated.Value)
{
    var srcPayload = new UserRequest
    {
        UserTypeId = userType.UserTypeId,
        Data = userItemResponse.Data
    };
        await _randomHelper.UpdateUserAsync(srcPayload, payload, userType.Code);
}
else
{
    await _randomHelper.RegisterUserAsync(payload, userType.Code);
}

The part of the code that was responsible for user modification was moved to a separate method. Additionally, an ‘early return’ statement was added to reduce cyclomatic complexity.

This is a clear example of overengineering, where syntax sugar is used to save just 4 lines of code but results in convoluted and unreadable code. In contrast, using simple programming techniques like early return helps maintain a good balance.

Summary

After we started actively using pattern matching in our application, we realized that while it is a very powerful tool, it must be used carefully as it can sometimes impair code readability. Common sense and choosing the proper programming technique for the right context are always the best approaches.

<p>Loading...</p>