I have a class ScoreStrategy
that describes how to calculate points for a quiz:
public class ScoreStrategy
{
public int Id { get; set; }
public int QuizId { get; set; }
[Required]
public Quiz Quiz { get; set; }
public decimal Correct { get; set; }
public decimal Incorrect { get; set; }
public decimal Unattempted { get; set; }
}
Three properties Correct
, Incorrect
and Unattempted
describe how many points to be assigned for a response. These points can also be negative. The score strategy applies to all questions in the quiz, thus there can only be one ScoreStrategy
per quiz.
I have two subclasses:
public class DifficultyScoreStrategy : ScoreStrategy
{
public QuestionDifficulty Difficulty { get; set; }
}
public class QuestionScoreStrategy : ScoreStrategy
{
[Required]
public Question Question { get; set; }
}
My questions have three difficulty levels(Easy
, Medium
, Hard
; QuestionDifficulty
is an enum). The DifficultyScoreStrategy
specifies if points for questions of a specific difficulty need to be assigned differently. This overrides the base ScoreStrategy
that applies to the entire quiz. There can be one instance per difficulty level.
Thirdly, I have a QuestionScoreStrategy
class that specifies if points for a specific question have to be awarded differently. This overrides both the quiz-wide ScoreStrategy
and the difficulty-wide DifficultyStrategy
. There can be one instance per question.
While evaluating the responses of the quiz, I want to implement a level-by-level fallback mechanism:
For each question:
QuestionScoreStrategy
for the question and return the strategy if one is found.DifficultyScoreStrategy
and check if there is a strategy for the difficulty level of the question being evaluated
and return it if a strategy is found.ScoreStrategy
and check if one exists and return it if it does,ScoreStrategy
either, use default as { Correct = 1, Incorrect = 0, Unattempted = 0 }
(It would be great if I can make this configurable as well, something much like the .NET's elegant way:options => {
options.UseFallbackStrategy(
correct: 1,
incorrect: 0,
unattempted: 0
);
}
).
I've summarized the above info in a table:
Strategy Type | Priority | Maximum instances per quiz |
---|---|---|
QuestionScoreStrategy |
1st (highest) | As many as there are questions in the quiz |
DifficultyScoreStrategy |
2nd | 4, one for each difficulty level |
ScoreStrategy |
3rd | Only one |
Fallback strategy (Default { Correct = 1, Incorrect = 0, Unattempted = 0} ) |
4th (lowest) | Configured for the entire app. Shared by all quizzes |
I have a container class called EvaluationStrategy
that holds these score strategies among other evaluation info:
partial class EvaluationStrategy
{
public int Id { get; set; }
public int QuizId { get; set; }
public decimal MaxScore { get; set; }
public decimal PassingScore { get; get; }
public IEnumerable<ScoreStrategy> ScoreStrategies { get; set; }
}
I have added a method called GetStrategyByQuestion()
to the same EvaluationStrategy
class above(note it is declared as partial
) that implements this fallback behavior and also a companion indexer that in turn calls this method. I have declared two HashSet
s of types DifficultyScoreStrategy
and QuestionScoreStrategy
and an Initialize()
method instantiates them. All the score strategies are then switched by type and added to the appropriate HashSet
, there can only be one ScoreStrategy
per quiz, which will be stored in defaultStrategy
:
partial class EvaluationStrategy
{
private ScoreStrategy FallbackStrategy = new() { Correct = 1, Incorrect = 0, Unattempted = 0 };
private ScoreStrategy defaultStrategy;
HashSet<DifficultyScoreStrategy> dStrategies;
HashSet<QuestionScoreStrategy> qStrategies;
public void Initialize()
{
qStrategies = new();
dStrategies = new();
// Group strategies by type
foreach (var strategy in strategies)
{
switch (strategy)
{
case QuestionScoreStrategy qs: qStrategies.Add(qs); break;
case DifficultyScoreStrategy ds: dStrategies.Add(ds); break;
case ScoreStrategy s: defaultStrategy = s; break;
}
}
}
public ScoreStrategy this[Question question] => GetStrategyByQuestion(question);
public ScoreStrategy GetStrategyByQuestion(Question question)
{
if (qStrategies is null || dStrategies is null)
{
Initialize();
}
// Check if question strategy exists
if (qStrategies.FirstOrDefault(str => str.Question.Id == question.Id) is not null and var qs)
{
return qs;
}
// Check if difficulty strategy exists
if (dStrategies.FirstOrDefault(str => str.Question.Difficulty == question.Difficulty) is not null and var ds)
{
return ds;
}
// Check if default strategy exists
if (defaultStrategy is not null)
{
return defaultStrategy;
}
// Fallback
return FallbackStrategy;
}
}
This method seems a bit clumsy and doesn't quite feel right to me. Using a partial class and adding to EvalutationStrategy
doesn't seem right either. How do I implement this level-by-level fallback behavior? Is there a design pattern/principle I can use here? I know many things in the .NET framework fallback to default conventions if not configured. I need something similar. Or can someone simply recommend a cleaner and elegant solution with better maintainability?
NOTE/ADDITIONAL INFO: The ScoreStrategy
s and EvaluationStrategy
for all quizzes are stored in a database managed by EF Core(.NET 5) with TPH mapping:
modelBuilder.Entity<ScoreStrategy>()
.ToTable("ScoreStrategy")
.HasDiscriminator<int>("StrategyType")
.HasValue<ScoreStrategy>(0)
.HasValue<DifficultyScoreStrategy>(1)
.HasValue<QuestionScoreStrategy>(2)
;
modelBuilder.Entity<EvaluationStrategy>().ToTable("EvaluationStrategy");
I have a single base DbSet<ScoreStrategy> ScoreStrategies
and another DbSet<EvaluationStrategy> EvaluationStrategies
. Since EvaluationStrategy
is an EF Core class, I'm a bit skeptical about adding logic(GetStrategyByQuestion()
) to it as well.
There is a 3rd party library called Polly which defines a policy called Fallback.
With this approach you can easily define a fallback chain like this:
public ScoreStrategy GetStrategyByQuestionWithPolly(Question question)
{
Func<ScoreStrategy, bool> notFound = strategy => strategy is null;
var lastFallback = Policy<ScoreStrategy>
.HandleResult(notFound)
.Fallback(FallbackStrategy);
var defaultFallback = Policy<ScoreStrategy>
.HandleResult(notFound)
.Fallback(defaultStrategy);
var difficultyFallback = Policy<ScoreStrategy>
.HandleResult(notFound)
.Fallback(() => GetApplicableDifficultyScoreStrategy(question));
var fallbackChain = Policy.Wrap(lastFallback, defaultFallback, difficultyFallback);
fallbackChain.Execute(() => GetApplicableQuestionScoreStrategy(question));
}
I've extracted the strategy selection logic for QuestionScoreStrategy
and DifficultyScoreStrategy
like this:
private ScoreStrategy GetApplicableQuestionScoreStrategy(Question question)
=> qStrategies.FirstOrDefault(str => str.Question.Id == question.Id);
private ScoreStrategy GetApplicableDifficultyScoreStrategy(Question question)
=> dStrategies.FirstOrDefault(str => str.Difficulty == question.Difficulty);
return
statementIf you don't want to use a 3rd party library just to define and use a fallback chain you do something like this:
public ScoreStrategy GetStrategyBasedOnQuestion(Question question)
{
var fallbackChain = new List<Func<ScoreStrategy>>
{
() => GetApplicableQuestionScoreStrategy(question),
() => GetApplicableDifficultyScoreStrategy(question),
() => defaultStrategy,
() => FallbackStrategy
};
ScoreStrategy selectedStrategy = null;
foreach (var strategySelector in fallbackChain)
{
selectedStrategy = strategySelector();
if (selectedStrategy is not null)
break;
}
return selectedStrategy;
}
return
statementYou can sort the sequence of ScoringMethods
by your priority.
First you sort by whether str is QuestionScoreStrategy
and str.Question.Id == question.Id
.
Then you sort by whether str is DifficultyScoreStrategy
and str.Question.Difficulty == question.Difficulty
.
(Note that since false
comes before true
, you'll have to invert the conditions)
Then you can just do FirstOrDefault() ?? defaultStrategy
.
Example:
var defaultStrategy = new() { Correct = 1, Incorrect = 0, Unattempted = 0 };
var selectedStrategy = Strategies.OrderBy(str =>
!(str is QuestionScoreStrategy questionStrat && questionStrat.Question.Id == question.Id)
).ThenBy(str =>
!(str is DifficultyScoreStrategy difficultySrat && difficultySrat.Difficulty == question.Difficulty)
).FirstOrDefault() ?? defaultStrategy;
You can easily add more "levels" to this by adding more ThenBy
clauses.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With