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:
- Check if there is a
QuestionScoreStrategy
for the question and return the strategy if one is found. - If not, fallback to
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. - If not, fallback to the quiz-wide
ScoreStrategy
and check if one exists and return it if it does, - If there is no
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
);
}
).
Summary
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; }
}
What I have tried:
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.