0

Cross-posting from SO.

Is there a name for the patterns used send messages/errors and results through an application's layers from business logic/database to the UI? What is the modern way?

For example: The application layers are setup as follows: UI <-> ViewModel <-> Business Logic <-> Database/Repository

A user wants to update a record, it's straight forward to pass that information down through the layers. If the update transaction passes or fails, I'd like to return something back to the UI. If there was a reason the update failed, I'd like to capture the reason and send that back to the UI.

In my case, I am returning a tuple<bool, string> where the bool is success/fail and string is the error message. Is this bad practice?

  • Should I be creating a "Result" class?
  • Are there other patterns or existing framework objects/type that do this?
  • Should the business logic class own/create the error strings for the UI? (I think yes, but tell me otherwise)

I came across this library: OneOf But not sure I want to implement this.

6
  • Why do you want to return "success" status to your UI? If you call a method and it doesn't throw any exceptions, then you should just assume that the operation completed successfully. And if it didn't complete successfully, it would have thrown an exception that you could have caught on your UI layer and displayed an appropriate error message to the user.
    – Aleksander
    Feb 4, 2021 at 3:03
  • @Aleksander I'm just being explicit. I suppose the default would return true/success and something else if something went wrong. In the case where the user just saves their work via a "save" button, I still intend to let the user know if the save was successful or not
    – GisMofx
    Feb 4, 2021 at 3:09
  • 1
    It seems to me that you are overthinking it. Everything you want to achieve with this is covered by exceptions. If user presses "Save" you call a method that saves user's work in a file. If the save failed for whatever reason, then you should throw an exception from that method. If the save succeeded, then after your method call you just display a message notifying the user that his work has been saved.
    – Aleksander
    Feb 4, 2021 at 3:23
  • @Aleksander - Exceptions are expensive and its not always desirable to throw them. Consider validation. Arguably, that's not shouldn't be an exception in the true sense of the word; and so returning a formatted; easy to read and understand response is both more performant and often more desirable. Aug 22, 2022 at 21:49
  • @RobertPerry “Exceptions are expensive and its not always desirable to throw them” - an absolutely empty statement. Yes, this is software engineering. Quite literally nothing is ALWAYS desirable when it comes to design patterns or architecture. It would also be nonsensical to avoid exceptions because they are “expensive” unless for some reason you are writing hyper optimized code in C# in which case exceptions should be the least of your worries. Validation is a completely other topic and does not relieve you of exceptions.
    – Aleksander
    Aug 23, 2022 at 22:37

2 Answers 2

2

OneOf is a pretty popular library among the .NET packages. Another really famous one is the FluentResults.

We have started to use OneOf like a year ago. We have faced with some of its limitations and that's why we have created a guideline how to use it in a bit more convenient way. In order to demonstrate the bad and good practices we have put together a sample application.

Even though it's quite lengthy I hope it helps you to decide whether or not the introduced complexity is worthwhile.


Problem domain

Requirements

Let’s suppose we have to implement a Discount Calculator for a webshop (or Discount Engine if you are looking for a fancy name).

Further on let’s suppose we have the following rules:

  • If the customer’s birthday has not specified then fail immediately
  • If the customer celebrates his/her birthday today then give him/her 25% discount
  • If the orders' total fee is above 10k then give him/her 15% discount
  • If there is no order (for a given customer) then treat him/her as a newcomer
  • If he/she is under 21 then fail immediately
  • If he/she is above 21 then give him/her 5% discount

So, we have three rules (Birthday, Previous Orders and Age). They should be evaluated in this order.

State diagram

enter image description here

Defining States

As you can see from the requirements (or from the diagram) that we have happy paths (with Percentage member) and unhappy paths (with Error member) as well.

Naive implementation

From the API consumer perspective they are all valid outcomes. So, we can define them just like this:

public abstract class DiscountCalculationResult
    : OneOfBase<
        DiscountCalculationResult.BirthdayDiscount,
        DiscountCalculationResult.BirthdayIsNotSet,
        DiscountCalculationResult.TotalFeeAbove10K,
        DiscountCalculationResult.Newcomer,
        DiscountCalculationResult.Under21,
        DiscountCalculationResult.Above21>
{
    public class BirthdayDiscount : DiscountCalculationResult
    {
        public double Percentage { get; set; }
    }

    public class BirthdayIsNotSet : DiscountCalculationResult
    {
        public Dictionary<string, object> ErrorData { get; set; }
    }

    public class TotalFeeAbove10K : DiscountCalculationResult
    {
        public double Percentage { get; set; }
    }

    public class Newcomer : DiscountCalculationResult
    {
        public double Percentage { get; set; }
    }

    public class Under21 : DiscountCalculationResult
    {
        public Dictionary<string, object> ErrorData { get; set; }
    }

    public class Above21 : DiscountCalculationResult
    {
        public double Percentage { get; set; }
    }
}

What have we done here?

  • We have created a base class to be able to represent all of the different states (DiscountCalculationResult).
  • Each state has its own set of properties (either Percentage or ErrorData).
  • The base has inherited from the OneOfBase in order to define this type as a discriminated union.

Distinguishing success and error

If you look at the definition of the above classes then you can spot the following drawbacks:

  1. There are some similarities but they are handled individually (for example: there is no common ground for happy scenarios).

  2. After result object creation each data field can be modified (there is a public setter for its primary property).

  3. OneOf’s built-in support to check the current state and retrieve it, is somewhat weird (check: IsTn, retrieve: AsTn).

In order to overcome of first two issues we can define some helper classes:

public abstract class SucceededDiscountCalculation : DiscountCalculationResult
{
    public double Percentage { get; }
    protected SucceededDiscountCalculation(double percentage) => Percentage = percentage;
}

public abstract class FailedDiscountCalculation : DiscountCalculationResult
{
    public Dictionary<string, object> ErrorData { get; }
    protected FailedDiscountCalculation(params (string Key, object Value)[] errorData)
      => ErrorData = errorData.ToDictionary(item => item.Key, item => item.Value);
}

Then we can change the state definitions in the following way to utilize these helpers:

public abstract class DiscountCalculationResult
    : OneOfBase<
        DiscountCalculationResult.BirthdayDiscount,
        DiscountCalculationResult.BirthdayIsNotSet,
        DiscountCalculationResult.TotalFeeAbove10K,
        DiscountCalculationResult.Newcomer,
        DiscountCalculationResult.Under21,
        DiscountCalculationResult.Above21>
{
    public class BirthdayDiscount : SucceededDiscountCalculation
    {
        public BirthdayDiscount() : base(25) { }
    }

    public class BirthdayIsNotSet : FailedDiscountCalculation
    {
        public BirthdayIsNotSet(params (string Key, object Value)[] errorData) : base(errorData) { }
    }

    public class TotalFeeAbove10K : SucceededDiscountCalculation
    {
        public TotalFeeAbove10K() : base(15) { }
    }

    public class Newcomer : SucceededDiscountCalculation
    {
        public NewComer() : base(0) { }
    }

    public class Under21 : FailedDiscountCalculation
    {
        public Under21(params (string Key, object Value)[] errorData): base(errorData) { }
    }

    public class Above21 : SucceededDiscountCalculation
    {
        public Above21(): base(5) {}
    }
}

To fix the 3rd problem we can use C# 7’s pattern matching and is type pattern expression:

DiscountCalculationResult result = ...;
if (result is DiscountCalculationResult.BirthdayDiscount bDayDiscount)
   return bDayDiscount;

In case unit test you should use the Shouldly’s ShouldBeOfType method

var result = SUT....;
var bDayDiscount = result.ShouldBeOfType<DiscountCalculationResult.BirthdayDiscount>();
bDayDiscount.Percentage.ShouldBe(25);

Implementing behaviour

Naive implementation

Now, let’s implement the three different rules.

private OneOf<DiscountCalculationResult.BirthdayDiscount, DiscountCalculationResult.BirthdayIsNotSet, No> PerformBirthDayRule(DateTime? dateOfBirth)
{
    if (!dateOfBirth.HasValue) return new DiscountCalculationResult.BirthdayIsNotSet();

    var today = DateTime.Now.Date;
    if(today.Month == dateOfBirth.Value.Month && today.Day == dateOfBirth.Value.Day)
        return new DiscountCalculationResult.BirthdayDiscount();

    return new No();
}

private OneOf<DiscountCalculationResult.TotalFeeAbove10K, DiscountCalculationResult.Newcomer, Some> PerformPreviousOrdersRule(double ordersTotal)
{
    return Math.Abs(ordersTotal) switch
    {
        var total when total < 1D => new DiscountCalculationResult.Newcomer(),
        var total when total > 10000D => new DiscountCalculationResult.TotalFeeAbove10K(),
        _ => new Some()
    };
}

private OneOf<DiscountCalculationResult.Above21, DiscountCalculationResult.Under21> PerformAgeRule(int age)
{
    if (age >= 21) 
        return new DiscountCalculationResult.Above21();

    return new DiscountCalculationResult.Under21((nameof(age), age));
}

It is really easy to spot which rule can return with which state.

You can also see there are two extra states that we haven’t talked about (yet). These two (built-in) states are the No and the Some.

  • Former can be used to describe “Condition is not met“, in our case today is not his/her birthday.

  • Latter is used to describe ”Condition is met, but we don’t care about further details”, in our case the total fee is between 0 and 10k, but we are not interested about the actual value.

    • Feel free to use Yes if it is more convenient for you.

Let’s see how to compose the engine by using these rules:

interface IDiscountEngine
{
    DiscountCalculationResult CalculateDiscount(DateTime? dateOfBirth, double ordersTotal);
}

class DiscountEngine: IDiscountEngine
{
    public DiscountCalculationResult CalculateDiscount(DateTime? dateOfBirth, double ordersTotal)
    {
        var birthdayRuleResult = PerformBirthDayRule(dateOfBirth);
        if (birthdayRuleResult.IsT0) return birthdayRuleResult.AsT0;
        if (birthdayRuleResult.IsT1) return birthdayRuleResult.AsT1;

        var previousOrdersRuleResult = PerformPreviousOrdersRule(ordersTotal);
        if (previousOrdersRuleResult.IsT0) return previousOrdersRuleResult.AsT0;
        if (previousOrdersRuleResult.IsT1) return previousOrdersRuleResult.AsT1;

        var today = DateTime.Today;
        var age = today.Year - dateOfBirth.Value.Year;
        if (dateOfBirth.Value.Date > today.AddYears(-age)) age--;

        var ageRuleResult = PerformAgeRule(age);
        return ageRuleResult.Match<DiscountCalculationResult>(
            above => above,
            under => under);
    }
}

Overcoming limitations

The previously showed approach has several limitations / drawbacks:

  1. When you look at the code (without the proper knowledge of which state represents what) you can’t tell which return type can be considered as success and which one as failure.

  2. You can’t use Match or Switch everywhere because there are some states (No, Some) that are not considered as final states.

  3. T0 can be either a success case or a failure depending on the order of the OneOf’s generic type parameters. (OneOf<error, succes> vs OneOf<success, error>)

  4. If you need to extend one of the rules with a new final state (for example if ordersTotal is above 20k then give 20% discount) you need to modify the code in several places.

In order to fix these issues we need to make use of the Success and the Error structs.

So, lets rewrite the rules to return with OneOf<Success<DiscountCalculationResult>, Error<DiscountCalculationResult>>:

private OneOf<Success<DiscountCalculationResult>, Error<DiscountCalculationResult>, No> PerformBirthDayRule(DateTime? dateOfBirth)
{
    if (!dateOfBirth.HasValue) 
        return new Error<DiscountCalculationResult>(new DiscountCalculationResult.BirthdayIsNotSet());

    var today = DateTime.Now.Date;
    if (today.Month == dateOfBirth.Value.Month && today.Day == dateOfBirth.Value.Day)
        return new Success<DiscountCalculationResult>(new DiscountCalculationResult.BirthdayDiscount());

    return new No();
}

private OneOf<Success<DiscountCalculationResult>, Error<DiscountCalculationResult>, Some> PerformPreviousOrdersRule(double ordersTotal)
{
    return Math.Abs(ordersTotal) switch
    {
        var total when total < 1D 
            => new Error<DiscountCalculationResult>(new DiscountCalculationResult.Newcomer()),
        var total when total > 10000D && total < 20000D
            => new Success<DiscountCalculationResult>(new DiscountCalculationResult.TotalFeeAbove10K()),
        var total when total >= 20000D
            => new Success<DiscountCalculationResult>(new DiscountCalculationResult.TotalFeeAbove20K()),
        _ => new Some()
    };
}

private OneOf<Success<DiscountCalculationResult>, Error<DiscountCalculationResult>> PerformAgeRule(int age)
{
    if (age >= 21)
        return new Success<DiscountCalculationResult>(new DiscountCalculationResult.Above21());

    return new Error<DiscountCalculationResult>(new DiscountCalculationResult.Under21((nameof(age), age)));
}

Re #1: With this approach you are making really explicit which state can be considered as happy path and which one as unhappy.

Re #2: It is easy to extend with a new final state (as you can see the TotalFeeAbove20K).

  • From the rules consumption point of view it does not matter most of the time how many success and failure cases are.

In order to tackle the 3rd and 4th issues we can introduce the following helper methods:

public static class OneOfExtensions
{
    //They cover the PerformBirthdayRule scenarios 
    public static bool HasFinalState<T0, T1>(this OneOf<T0, T1, No> option) => !option.IsT2;
    public static T0 GetResult<T0, T1>(this OneOf<Success<T0>, Error<T1>, No> option) => option.AsT0.Value;
    public static T1 GetError<T0, T1>(this OneOf<Success<T0>, Error<T1>, No> option) => option.AsT1.Value;
    public static bool HasSucceeded<T0, T1>(this OneOf<Success<T0>, Error<T1>, No> option) => option.IsT0;
    public static bool HasFailed<T0, T1>(this OneOf<Success<T0>, Error<T1>, No> option) => option.IsT1;

    //They cover the PerformPreviousOrdersRule scenarios
    public static bool HasFinalState<T0, T1>(this OneOf<T0, T1, Some> option) => !option.IsT2;
    public static T0 GetResult<T0, T1>(this OneOf<Success<T0>, Error<T1>, Some> option) => option.AsT0.Value;
    public static T1 GetError<T0, T1>(this OneOf<Success<T0>, Error<T1>, Some> option) => option.AsT1.Value;
    public static bool HasSucceeded<T0, T1>(this OneOf<Success<T0>, Error<T1>, Some> option) => option.IsT0;
    public static bool HasFailed<T0, T1>(this OneOf<Success<T0>, Error<T1>, Some> option) => option.IsT1;

    //They cover the PerformAgeRule scenarios
    public static bool HasSucceeded<T0, T1>(this OneOf<Success<T0>, Error<T1>> option) => option.IsT0;
    public static bool HasFailed<T0, T1>(this OneOf<Success<T0>, Error<T1>> option) => option.IsT1;
    public static T0 GetResult<T0, T1>(this OneOf<Success<T0>, Error<T1>> option) => option.AsT0.Value;
    public static T1 GetError<T0, T1>(this OneOf<Success<T0>, Error<T1>> option) => option.AsT1.Value;
}

By utilizing these methods the CalculateDiscount could be expressed in the following way:

public DiscountCalculationResult CalculateDiscount(DateTime? dateOfBirth, double ordersTotal)
{
    var birthdayRuleResult = PerformBirthDayRule(dateOfBirth);
    if (birthdayRuleResult.HasFinalState())
    {
        if (birthdayRuleResult.HasSucceeded()) return birthdayRuleResult.GetResult();
        if (birthdayRuleResult.HasFailed()) return birthdayRuleResult.GetError();
    }

    var previousOrdersRuleResult = PerformPreviousOrdersRule(ordersTotal);
    if (previousOrdersRuleResult.HasFinalState())
    {
        if (previousOrdersRuleResult.HasSucceeded()) return previousOrdersRuleResult.GetResult();
        if (previousOrdersRuleResult.HasFailed()) return previousOrdersRuleResult.GetError();
    }

    var today = DateTime.Today;
    var age = today.Year - dateOfBirth.Value.Year;
    if (dateOfBirth.Value.Date > today.AddYears(-age)) age--;

    var ageRuleResult = PerformAgeRule(age);
    return ageRuleResult.HasSucceeded() ? ageRuleResult.GetResult() : ageRuleResult.GetError();
}

NOTE: Obviously if we don’t distinguish (at this layer) the happy and the unhappy results then you can reduce the extension methods. Here we have defined these in order to illustrate how easily you define your own.

Handling all cases

By using the Switch it is mandatory to handle all of the cases. If you introduce a new valid final state then you have to handle it because without that the code would not compile.

Naive implementation

[HttpGet]
public IActionResult Get()
{
    //TODO: retrieve date of birth and total order count
    var result = var calculationResult = engine.CalculateDiscount(dateOfBirth, orderTotal);    
    IActionResult actionResult = null;
    result.Switch(
        bDayDiscount => actionResult = Ok(bDayDiscount.Percentage),
        bDayIsNotSet => {
            _logger.Log(LogLevel.Information, "BirthDay was not set");
            actionResult = StatusCode(StatusCodes.Status302Found, "Profile/Edit");
        },
        totalAbove10K => actionResult = Ok(totalAbove10K.Percentage),
        totalAbove20K => actionResult = Ok(totalAbove20K.Percentage),
        newcomer => actionResult = Ok(newcomer.Percentage),
        under21 => {
            _logger.Log(LogLevel.Information, $"Customer is under {under21.ErrorData.First().Value}");
            actionResult = StatusCode(StatusCodes.Status403Forbidden);
        },
        above21 => actionResult = Ok(above21.Percentage)
        );
    return actionResult;
}

The usage of ValueTuples

One of the biggest problem with the above code is that is repeats a lot of assignments and method calls.

A better approach would be to map the different cases to data tuples and then have a single code path which executes code based on the data.

[HttpGet("{age}/{total}")]
public IActionResult Get(byte age, double total)
{
    var days = rnd.Next() % 2;   
    var dateOfBirth = DateTime.Now.AddYears(-age).AddDays(days);
            
    var result = var calculationResult = engine.CalculateDiscount(dateOfBirth, orderTotal); 
    var mapping = result.Match<(IActionResult response, (LogLevel level, string message) logging)>(
        bDayDiscount => (Ok(bDayDiscount.Percentage), (LogLevel.Information, "Yet another lucky one, who has a b.day today.")),
        bDayIsNotSet => (StatusCode(StatusCodes.Status302Found, "Profile/Edit"), (LogLevel.Information, "BirthDay was not set")),
        totalAbove10K => (Ok(totalAbove10K.Percentage), default),
        totalAbove20K => (Ok(totalAbove20K.Percentage), default),
        newcomer => (Ok(newcomer.Percentage), default),
        under21 => (StatusCode(StatusCodes.Status403Forbidden), (LogLevel.Information, $"Customer is under {under21.ErrorData.First().Value}")),
        above21 => (Ok(above21.Percentage), default)
        );

    if (mapping.logging != default)
    {
        _logger.Log(mapping.logging.level, mapping.logging.message);
    }

    return mapping.response;
}
1

One common approach is using exceptions if your language supports that concept.

You can directly use exceptions within one execution environment to indicate that something has failed and pass that information through the layers of one application if they are communicating via the call stack.

When passing application boundaries (for example when using a REST endpoint from an SPA in the browser), you can perhaps use the transport protocol (like HTTP) to transfer success and error responses.

If the protocol (which also might be a bundle of seemingly independent asynchronous events) cannot already distinguish errors from regular results cases, it's helpful to create an own response class for wrapping the actual response or exception (optionally indicating the kind of response/error).

You can then recreate an exception from that in the entered application to continue passing it down.

Ultimately you will arrive at a place where you can actually handle the error (perhaps by just showing a message or by retrying etc.).

2
  • Thanks. How about bubbling errors up from within database access class through business logic to view model? The database class would throw the error where is the try/catch block?
    – GisMofx
    Feb 4, 2021 at 23:20
  • 1
    You can add intermediate catch-blocks for translating exceptions to other exceptions (they can be part of the API of layers) and logging them when leaving the application boundary (at least if it's a technical error). Where to place the final catch-block which actually handles the error depends on the kind of error and where it can really be dealt with in a meaningful way. You should distinguish transient errors of retryable actions and user-caused/fixable errors which need a change of the data. Feb 5, 2021 at 0:37

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.