Strategy Pattern for Calculating Book rental price.

Strategy Pattern for Calculating Book rental price.

·

9 min read

In our previous article, we employed the Factory Pattern to create different types of books. In this article, we want to implement a system where the book rental price can be set differently for each book type. We want to achieve this in a way that allows the price to be changed regularly while minimizing the impact on our application, such as code changes and rewrites.

In our library system, we want to have a way to calculate the rental price of each book type. For example, for the current month, we want to apply a 10% discount on paperback books and a 20% discount on audiobooks. These discounts can change in the following month to a different rate.

In an antipattern approach, we could use if/else conditions to calculate the rental price for each book type. This may seem reasonable at first, but it can get complicated quickly as we add more and more book types. Additionally, if we need to change the discount rates next month, such as giving only 5% off for paperback books and 15% for audiobooks, this approach becomes unsustainable. Updating the code each time we add a new book type or change the discount rates is not efficient. This is where the Strategy Pattern can help us.

The Strategy Pattern is a behavioral design pattern that enables selecting an algorithm's behavior at runtime. Instead of implementing a single algorithm directly, the code receives run-time instructions on which algorithm from a family of algorithms to use.

Concept

  • Strategy Interface: This defines a common interface for all supported algorithms. Context uses this interface to call the algorithm defined by a Concrete Strategy.

  • Concrete Strategies: Implement different variations of an algorithm, adhering to the Strategy interface.

  • Context: Maintains a reference to a Strategy object and is configured with a Concrete Strategy to execute the desired behavior.

Purpose

  • Encapsulate Algorithm Variations: Encapsulates each variation of an algorithm so that each variation can be selected and used interchangeably at runtime.

  • Avoid Conditional Statements: Avoids conditional statements for selecting desired behaviors. Instead of using multiple conditionals, the behavior is encapsulated in strategy classes.

  • Ease of Extension: New behaviors can be introduced without changing the context by adding new strategy classes.

First, we will explore a flavor of the Strategy Pattern through the use of the Func delegate. This Func delegate acts as a strategy for calculating the rental cost of a book. This pattern is particularly useful in scenarios where the algorithm might vary based on certain conditions, such as the type of book in this case. It serves as the strategy interface. Instead of defining a family of algorithms through an interface or an abstract class, this approach uses a delegate that can point to any method matching its signature, providing a flexible way to change the algorithm dynamically

public async Task<Book?> GetByIdAsync(int id)
{
    var book = await _unitOfWork.Books.GetByIdAsync(id);

    if (book == null)
    {
        return null;
    }

    // Define the rental cost calculation based on the book type
    Func<Book, decimal> rentalCostCalculationFunc = b =>
    {
        // 10% discount for audio, 20% for others
        decimal discount = (b.Type == BookType.AudioBook ? 0.9m : b.Type == BookType.PaperBack ? 0.8m : 1.0m); 
        return 1m * discount;
    };

    // Apply the rental cost calculation
    book = SetRentalCost(book, rentalCostCalculationFunc);

    return book;
}

Implementing this is relatively simple. We would take the existing GetByIdAsync() method and add a Func delegate definition calls rentalCostCalculationFunc. This delegate's sole responsibility is to assign a discount rate based on the type of book we are working with. Once that discount rate is set, we can feed it into a function that calculates the book's rental price.

To keep things simple, we assume all book rental prices start at a base of $1. If the discount is 20%, then the final rental price is $0.80.

private Book SetRentalCost(Book book, Func<Book, decimal> rentalCostCalculationFunc)
{
    if (book == null)
    {
        throw new ArgumentNullException(nameof(book), "Book cannot be null.");
    }

    if (rentalCostCalculationFunc == null)
    {
        throw new ArgumentNullException(nameof(rentalCostCalculationFunc), "Rental cost calculation function cannot be null.");
    }

    // Invoke the function with the book as an argument to calculate the rental price
    book.RentalPrice = rentalCostCalculationFunc(book);

    return book;
}

For PapperBack book, our API shows it calculate the rental price accordingly which is 20% of the original price.

For Audio book, only 10% discount is applied and thus the price came to $0.9

This works well but there is a problem but the function GetByIdAsync as provided does exhibit concerns that could be seen as violations of the Strategy Pattern and the principle of Separation of Concerns (SoC):

  1. Strategy Pattern Violation: The Strategy Pattern involves defining a family of algorithms, encapsulating each one, and making them interchangeable. The function GetByIdAsync directly embeds the logic for calculating the rental cost based on the book type within itself. This approach does not fully leverage the Strategy Pattern because it does not separate the algorithm (rental cost calculation) from the context (retrieving and processing the book). Instead, it combines them, which makes changing the calculation logic or adding new strategies more difficult and less flexible.

  2. Separation of Concerns Violation: SoC is a design principle for separating a computer program into distinct sections, such that each section addresses a separate concern. In this case, the GetByIdAsync function is responsible for both accessing the book data and determining its rental cost. The rental cost calculation is a distinct concern that should ideally be separated from the data access logic. Embedding the calculation logic within the data retrieval function mixes concerns that could be more cleanly separated, making the function less modular and harder to maintain or extend.

To adhere more closely to the Strategy Pattern and maintain Separation of Concerns, we could refactor the code as follows:

  • Extract Rental Cost Calculation: Define a separate strategy interface for rental cost calculation and implement concrete strategies for each book type. This allows for the easy addition of new calculation methods or adjustment of existing ones without modifying the book retrieval logic.

Here's a simplified refactoring example that demonstrates these principles in GetByIdAsync()

public async Task<Book?> GetByIdAsync(int id)
{
    var book = await _unitOfWork.Books.GetByIdAsync(id);

    if (book == null) return null;

    // Select the appropriate discount strategy based on the book type
    IDiscountStrategy discountStrategy = book.Type switch
    {
        BookType.AudioBook => new AudioBookDiscountStrategy(),
        BookType.PaperBack => new PaperBackDiscountStrategy(),
        _ => new DefaultDiscountStrategy()
    };

    Func<Book, decimal> rentalCostCalculationFunc = b => discountStrategy.CalculateDiscount(b);

    // Apply the rental cost calculation
    book = SetRentalCost(book, rentalCostCalculationFunc);

    return book;
}

As the above code points out, we move our implementation of the discount rate into its own interface, IDiscountStrategy. Currently, we hardcode the value, but it can be read from the application configuration. This gives us the flexibility to change the discount rate without needing to modify any code. With this code in place, we should obtain the same result from our Postman test.

using library_system.Entities;

namespace library_system.Services
{
    public interface IDiscountStrategy
    {
        decimal CalculateDiscount(Book book);
    }

    public class AudioBookDiscountStrategy : IDiscountStrategy
    {
        public decimal CalculateDiscount(Book book) => 0.9m; // 10% discount
    }

    public class PaperBackDiscountStrategy : IDiscountStrategy
    {
        public decimal CalculateDiscount(Book book) => 0.8m; // 20% discount
    }

    public class DefaultDiscountStrategy : IDiscountStrategy
    {
        public decimal CalculateDiscount(Book book) => 1.0m; // No discount
    }
}

At this point, we could consider this an acceptable implementation. However, personally, I never like seeing "new" dependencies in any of the code because it makes it difficult to write unit tests. We have the following resolver code inside GetByIdAsync(), which we can improve by moving it out of this method and calling it through Dependency Injection.

    IDiscountStrategy discountStrategy = book.Type switch
    {
        BookType.AudioBook => new AudioBookDiscountStrategy(),
        BookType.PaperBack => new PaperBackDiscountStrategy(),
        _ => new DefaultDiscountStrategy()
    };

Using a Func delegate in the Strategy Pattern versus a normal Dependency Injection (DI) implementation presents different trade-offs in terms of flexibility, complexity, and maintainability. Here's a comparison:

UsingFunc Delegate

  • Flexibility: Func delegates offer high flexibility. We can quickly inject different behaviors without defining multiple classes or interfaces. This is particularly useful for simple scenarios or when the behavior can be expressed as a single method.

  • Simplicity: For straightforward logic, using a Func can keep the implementation simple. It avoids the boilerplate code associated with setting up interfaces and classes.

  • Complexity Handling: As the logic becomes more complex, using Func delegates can lead to challenges. Complex logic might require multiple parameters, making the Func delegate cumbersome to use. Additionally, if the logic involves multiple steps or operations that don't fit well into a single method signature, maintaining a Func delegate becomes difficult.

  • Testability: Testing can be straightforward by passing in different Func delegates for different scenarios. However, as complexity grows, setting up these delegates for tests can become unwieldy.

Using Dependency Injection

  • Maintainability: DI encourages the use of interfaces and concrete implementations, which can make the codebase more organized and maintainable, especially as complexity grows. Each strategy can be encapsulated in its own class, adhering to the Single Responsibility Principle.

  • Complexity Handling: DI shines as the complexity of the logic increases. With each strategy implemented as a separate class, we can easily manage complex logic, state, and dependencies specific to each strategy.

  • Testability: DI is highly favorable for testing, especially with complex scenarios. Mocking frameworks can easily work with interfaces, allowing for straightforward unit testing of classes that depend on various strategies.

  • Overhead: The main drawback of using DI for the Strategy Pattern is the additional overhead of creating interfaces and classes for each strategy. This can be seen as unnecessary complexity for simple scenarios.

Conclusion

  • For Simple Logic: If the strategy logic is simple and unlikely to grow in complexity, using a Func delegate can be a quick and flexible approach.

  • For Complex Logic: As the logic becomes more complex or if you anticipate needing to manage multiple strategies with different behaviors, dependency injection provides a more scalable and maintainable framework. It better supports the principles of clean architecture, making it easier to extend and test your application.

In summary, the choice between using a Func delegate and normal DI for implementing the Strategy Pattern should be based on the complexity of the logic you're encapsulating, the need for maintainability and testability, and the overall architecture of your application.

In my next article, I will explore the Dependency Injection implementation of Strategy Pattern.

In summary, this article extends our previous discussion on the Factory Pattern by demonstrating how to use the Strategy Pattern to dynamically calculate rental prices for different book types in a library system. The Strategy Pattern helps avoid cumbersome if/else conditions and facilitates easy updates to pricing algorithms. The article provides code examples to illustrate the use of Func delegates and Dependency Injection for implementing the Strategy Pattern, comparing their pros and cons in terms of flexibility, complexity, and testability.

Code reference: https://github.com/amphan613/library-managment-system/tree/dc53481b1944843e13a8d9cbcbceb59fa61e6b9a