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

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:
There are some similarities but they are handled individually (for example: there is no common ground for happy scenarios).
After result object creation each data field can be modified (there is a public setter
for its primary property).
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:
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.
You can’t use Match
or Switch
everywhere because there are some states (No
, Some
) that are not considered as final states.
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>
)
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;
}