7

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 HashSets 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 ScoreStrategys 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.

Amal K
  • 4,359
  • 2
  • 22
  • 44

3 Answers3

1

You 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.

Sweeper
  • 213,210
  • 22
  • 193
  • 313
  • Nice solution. But I have a lot of questions to be evaluated. In a loop, I first get the strategy for each question in my collection and then evaluate it. Wouldn't it be expensive to perform a sort for each question? – Amal K Jun 02 '21 at 16:03
  • 1
    @AmalK Well, this kind of thing should really be done on the database, but I'm not familiar with EF enough to know if the lambdas can be translated to queries. But if you really care about performance, this can technically be done in a single pass, but in an ugly way. See [my answer on this post](https://stackoverflow.com/a/67267331/5133585). My answer here is actually inspired by Eritrean's answer there. Ideally, `Min` would accept a `Comparer` as argument, and you can compose `Comparer`s, but it doesn't :( – Sweeper Jun 03 '21 at 00:13
  • At the moment, I'm loading all the strategies from the database in one go and passing it to the `Evalaute` method. I really wonder if there is a pattern that helps with this. In fact, I've noticed a lot of things in the .NET framework are implemented that way, configuration uses the defaults while still allowing us to customize it. I'm talking about implementing [convention over configuration](https://en.wikipedia.org/wiki/Convention_over_configuration). But maybe that should be a separate question. – Amal K Jun 04 '21 at 09:45
1

With Polly

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);

Pros

  • There is a single return statement
  • The policy declarations are separated from chaining
  • Each and every fallback can be triggered by different conditions
  • Primary selection logic is separated from the fallbacks

Cons

  • The code is really repetitive
  • You can't create a fallback chain by utilizing a fluent API
  • You need to use a 3rd party library

Without Polly

If 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;
}

Pros

  • There is a single return statement
  • The fallback chain declaration and evaluation are separated
  • It is simple and concise

Cons

  • It is less flexible: each fallback selection is triggered by the same condition
  • Primary selection is not separated from fallbacks
Peter Csala
  • 17,736
  • 16
  • 35
  • 75
1

I imagine that all data (questions, strategies, quizes is stored in database). Then I would expect such ways of getting each strategy:

Question strategy

var questionStrategy = dbContext.ScoreStrategies.SingleOrDefault(ss => ss.QuesionId == question.Id);

Difficulty strategy:

var difficultyStrategy = dbContext.ScoreStrategies.SingleOrDefault(ss => ss.Difficulty == question.Difficulty);

Default strategy for quiz:

var quizStrategy = dbContext.ScoreStrategies.SingleOrDefault(ss => ss.QuizId == question.QuizId)

Building on this and what you already provided, strategy is just three numbers: points for correct answer, points for incorrect and unattempted answer.

So this makes perfect candidate for abstract class, which would serve for base class for three entities - three types of strategy - those will be three tables, because each has different relations:

public abstract class ScoreStrategy
{
    public double Correct { get; set; }
    public double Incorrect { get; set; }
    public double Unattempted { get; set; }
}
// Table with FK relation to Questions table
public class QuestionScoreStrategy : ScoreStrategy
{
    public Question { get; set; }
    public int QuestionId { get; set; }
}
// If you have table with difficulties, there should be FK relation to it.
// If you do not have table - it's worth consideration, you could then 
// easily add more difficulties.
public class DifficultyStrategy : ScoreStrategy
{
    public QuestionDifficulty Difficulty { get; set; }
}
// FK relation to Quizes table
public class QuizScoreStrategy : ScoreStrategy
{
    public Quiz { get; set; }
    public int QuizId { get; set; }
}

This way you end up with well grained tables that stores only relevant data.

Then, usage would become:

// Ideally, this method should be in some repoistory (look at repository design pattern) in data access layer
// and should leverage usage of async / await as well.
public ScoreStrategy GetScoreStrategy(Question question)
{
    return dbContext.QuestionScoreStrategies.SingleOrDefault(qs => qs.QuestionId == question.Id)
        ?? dbContext.DifficultyStrategies.SingleOrDefault(ds => ds.Difficulty == question.Difficulty)
        ?? dbContext.QuizScoreStrategies.SingleOrDefault(qs => qs.QuizId == question.QuizId);
}

Then you could use this method in such way:

// This should be outside data access layer. Here you perform logic of getting question.
// This could be some ScoringManager class which should be singleton (one instance only).
// Then you could define fallback in private fields:
private readonly double FALLBACK_CORRECT_SCORE;
private readonly double FALLBACK_INCORRECT_SCORE;
private readonly double FALLBACK_UNATTEMPTED_SCORE;
// private constructor, as this should be singleton
private ScoringManager(double correctScore, double incorrectScore, double unattemptedScore)
    => (FALLBACK_CORRECT_SCORE, FALLBACK_INCORRECT_SCORE, FALLBACK_UNATTEMPTED_SCORE) =
       (correctScore, incorrectScore, unattemptedScore);
 
public double CalcScoreForQuestion(Question question)
{
    var scoreStrategy = GetScoreStrategy(question);
    if (question answered correctly) 
        return scoreStrategy?.Correct ?? FALLBACK_CORRECT_SCORE;
    if (question answered incorrectly) 
        return scoreStrategy?.Incorrect ?? FALLBACK_INCORRECT_SCORE;
    if (question unattempted) 
        return scoreStrategy?.Unattempted ?? FALLBACK_UNATTEMPTED_SCORE;
}

NOTE

This is just the draft how I would organize things and most probably when writing code I would come up with improvements, but I think this is direction to go. For example ScoringManager could have ConfigureFallbackScore method, which would allow dynamically changing fallback scores (this would require making respective fields not readonly).

UPDATE

Define fallback strategy, in order to do that define enum:

public enum FallbackLevel
{
    Difficulty,
    Question,
    Quiz,
}

Then scoring manager could have method to configure strategy (together with backing fields):

private FallbackLevel _highPrecedence;
private FallbackLevel _mediumPrecedence;
private FallbackLevel _lowPrecedence;

public void ConfigureFallbackStrategy(FallbackLevel highPrecedence, FallbackLevel mediumPrecedence, FallbackLevel lowPrecedence)
{
    _highPrecedence = highPrecedence;
    _mediumPrecedence = mediumPrecedence;
    _lowPrecedence = lowPrecedence;
}

Then we would write getting strategy logic in manager:

public ScoreStrategy GetScoreStrategy(Question question)
{
   var scoreStrategy = GetScoreStrategy(_highPrecedence, question)
       ?? GetScoreStrategy(_mediumPrecedence, question)
       ?? GetScoreStrategy(_lowPrecedence, question);
}

private ScoreStrategy GetScoreStrategy(FallbackLevel lvl, Question question) => lvl switch
{
    FallbackLevel.Difficulty => dbContext.DifficultyStrategies.SingleOrDefault(ds => ds.Difficulty == question.Difficulty),
    FallbackLevel.Question => dbContext.QuestionScoreStrategies.SingleOrDefault(qs => qs.QuestionId == question.Id),
    FallbackLevel.Quiz => dbContext.QuizScoreStrategies.SingleOrDefault(qs => qs.QuizId == question.QuizId),
}

This way it is super easy to configure fallback strategy any way you want. Of course, there are some considerations still:

  • make sure that all fallback strategies are unique, so for example it is impossible to have high, medium and low startegy the same,
  • db context should be accessed only via repository pattern
  • add some more sanity checks (like nulls etc.)

I omitted those parts, as I focused on sheer functionality.

Michał Turczyn
  • 32,028
  • 14
  • 47
  • 69