🔥 Zapisy zamknięte, ale możesz pobrać Roadmapę .NET i dołączyć do listy oczekujących — Pobierz i dołącz do Listy VIP →

Strategy Pattern w C# — Sortowanie Produktów z Wyborem Algorytmu w Runtime

Kąt widzenia: klasyczny GoF — jeden kontekst, wiele wymienialnych algorytmów, wybór na podstawie danych użytkownika
Przykład przewodni: sortowanie i filtrowanie katalogu produktów w ASP.NET Core


Problem, który Strategy rozwiązuje

Masz endpoint w API: GET /products?sort=price_asc. Użytkownik może przekazać:

price_asc    — cena rosnąco
price_desc   — cena malejąco
popularity   — według popularności
newest       — od najnowszych
rating       — według ocen

Naiwna implementacja wygląda tak:

public IEnumerable<Product> GetSorted(IEnumerable<Product> products, string sort)
{
    return sort switch
    {
        "price_asc"  => products.OrderBy(p => p.Price),
        "price_desc" => products.OrderByDescending(p => p.Price),
        "popularity" => products.OrderByDescending(p => p.SalesCount),
        "newest"     => products.OrderByDescending(p => p.CreatedAt),
        "rating"     => products.OrderByDescending(p => p.AverageRating),
        _            => products
    };
}

Na razie jest OK. Problem zaczyna się, gdy:

  • Sortowanie wg popularności wymaga danych z zewnętrznego serwisu (np. clickstream analytics).
  • Sortowanie wg ratingu musi brać pod uwagę minimalną liczbę ocen, żeby nie było faworyzowania produktów z jedną gwiazdką.
  • Sortowanie personalizowane zależy od historii zakupów użytkownika.
  • Chcesz przetestować każde sortowanie niezależnie.

Switch zaczyna rosnąć. Metoda zna szczegóły każdego algorytmu. Logika testowania wszystkiego naraz jest uciążliwa. To jest dokładny moment dla Strategy.


Model domeny

public class Product
{
    public Guid Id { get; init; }
    public string Name { get; init; } = string.Empty;
    public decimal Price { get; init; }
    public int SalesCount { get; init; }
    public double AverageRating { get; init; }
    public int ReviewCount { get; init; }
    public DateTimeOffset CreatedAt { get; init; }
    public bool IsSponsored { get; init; }
}

public record ProductSortContext(
    IEnumerable<Product> Products,
    string? UserId = null);

ProductSortContext przekazuje do strategii wszystko, czego może potrzebować — produkty i opcjonalny kontekst użytkownika. Strategia nie wie nic o tym, skąd dane przyszły.


Interfejs strategii

public interface IProductSortStrategy
{
    string SortKey { get; }
    IOrderedEnumerable<Product> Sort(ProductSortContext context);
}

SortKey to identyfikator, po którym kontekst dopasowuje strategię do parametru z URL. Każda strategia mówi sama, za co jest odpowiedzialna — nie musisz utrzymywać osobnej mapy string → typ.


Konkretne strategie

Cena rosnąco / malejąco

public class PriceAscendingStrategy : IProductSortStrategy
{
    public string SortKey => "price_asc";

    public IOrderedEnumerable<Product> Sort(ProductSortContext context)
        => context.Products.OrderBy(p => p.Price);
}

public class PriceDescendingStrategy : IProductSortStrategy
{
    public string SortKey => "price_desc";

    public IOrderedEnumerable<Product> Sort(ProductSortContext context)
        => context.Products.OrderByDescending(p => p.Price);
}

Popularność (prosta — po SalesCount)

public class PopularityStrategy : IProductSortStrategy
{
    public string SortKey => "popularity";

    public IOrderedEnumerable<Product> Sort(ProductSortContext context)
        => context.Products
            .OrderByDescending(p => p.IsSponsored)  // sponsorowane pierwsze
            .ThenByDescending(p => p.SalesCount);
}

Oceny — z minimalnym progiem recenzji

To jest moment, w którym switch zaczyna pękać w швах — ta logika (minimalny próg) nie należy do głównego kontrolera ani repozytorium.

public class RatingStrategy : IProductSortStrategy
{
    private const int MinimumReviewThreshold = 10;

    public string SortKey => "rating";

    public IOrderedEnumerable<Product> Sort(ProductSortContext context)
    {
        // Produkty z za małą liczbą recenzji trafiają na koniec
        // żeby jeden produkt z jedną oceną 5.0 nie dominował listy
        return context.Products
            .OrderByDescending(p => p.ReviewCount >= MinimumReviewThreshold)
            .ThenByDescending(p => p.AverageRating)
            .ThenByDescending(p => p.ReviewCount);
    }
}

Najnowsze

public class NewestStrategy : IProductSortStrategy
{
    public string SortKey => "newest";

    public IOrderedEnumerable<Product> Sort(ProductSortContext context)
        => context.Products.OrderByDescending(p => p.CreatedAt);
}

Domyślna — gdy użytkownik nic nie wybrał

// Null Object Pattern — brak sortowania to też strategia
public class DefaultSortStrategy : IProductSortStrategy
{
    public string SortKey => "default";

    public IOrderedEnumerable<Product> Sort(ProductSortContext context)
        => context.Products.OrderBy(_ => 0); // zachowuje oryginalną kolejność
}

DefaultSortStrategy to przykład Null Object Pattern w połączeniu ze Strategy. Zamiast sprawdzać if (strategy == null) w kontekście — masz zawsze ważną strategię.


Kontekst (serwis katalogowy)

public class ProductCatalogService
{
    private readonly IReadOnlyDictionary<string, IProductSortStrategy> _strategies;
    private readonly IProductRepository _repository;
    private readonly ILogger<ProductCatalogService> _logger;

    public ProductCatalogService(
        IEnumerable<IProductSortStrategy> strategies,
        IProductRepository repository,
        ILogger<ProductCatalogService> logger)
    {
        // Budujemy słownik raz przy starcie — O(1) lookup w runtime
        _strategies = strategies.ToDictionary(s => s.SortKey, StringComparer.OrdinalIgnoreCase);
        _repository = repository;
        _logger = logger;
    }

    public async Task<IEnumerable<Product>> GetProductsAsync(
        ProductQuery query,
        string? userId = null)
    {
        var products = await _repository.GetAsync(query.CategoryId, query.SearchTerm);

        var strategy = _strategies.GetValueOrDefault(
            query.SortBy ?? "default",
            _strategies["default"]);

        _logger.LogInformation(
            "Sortowanie {Count} produktów strategią {Strategy} dla użytkownika {UserId}",
            products.Count(), strategy.SortKey, userId ?? "anonymous");

        var sortContext = new ProductSortContext(products, userId);
        return strategy.Sort(sortContext);
    }
}

public record ProductQuery(
    Guid? CategoryId = null,
    string? SearchTerm = null,
    string? SortBy = null);

Serwis nie zna ani jednej konkretnej klasy strategii. Wie tylko o interfejsie. Dodanie nowej strategii sortowania = nowa klasa + rejestracja w DI.


Integracja z ASP.NET Core — DI i kontroler

// Program.cs
builder.Services.AddScoped<IProductSortStrategy, PriceAscendingStrategy>();
builder.Services.AddScoped<IProductSortStrategy, PriceDescendingStrategy>();
builder.Services.AddScoped<IProductSortStrategy, PopularityStrategy>();
builder.Services.AddScoped<IProductSortStrategy, RatingStrategy>();
builder.Services.AddScoped<IProductSortStrategy, NewestStrategy>();
builder.Services.AddScoped<IProductSortStrategy, DefaultSortStrategy>();
builder.Services.AddScoped<ProductCatalogService>();
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly ProductCatalogService _catalogService;

    public ProductsController(ProductCatalogService catalogService)
        => _catalogService = catalogService;

    [HttpGet]
    public async Task<IActionResult> GetProducts(
        [FromQuery] Guid? categoryId,
        [FromQuery] string? search,
        [FromQuery] string? sort)
    {
        var userId = User.FindFirst("sub")?.Value;

        var query = new ProductQuery(categoryId, search, sort);
        var products = await _catalogService.GetProductsAsync(query, userId);

        return Ok(products);
    }
}

Kontroler nie wie nic o algorytmach sortowania. Przekazuje tylko parametry. Cała logika wyboru strategii jest w serwisie.


Unit testy — każda strategia niezależnie

To jest jedna z kluczowych korzyści Strategy: testujesz każdy algorytm w izolacji.

public class RatingStrategyTests
{
    private readonly RatingStrategy _sut = new();

    private static List<Product> BuildProducts() =>
    [
        new() { Name = "A", AverageRating = 4.9, ReviewCount = 2  },  // mało recenzji
        new() { Name = "B", AverageRating = 4.2, ReviewCount = 150 }, // dużo recenzji, niższa ocena
        new() { Name = "C", AverageRating = 5.0, ReviewCount = 1  },  // jedna recenzja 5.0
        new() { Name = "D", AverageRating = 4.5, ReviewCount = 80 },  // solidny produkt
    ];

    [Fact]
    public void Sort_ShouldPrioritizeProductsAboveReviewThreshold()
    {
        var context = new ProductSortContext(BuildProducts());
        var result = _sut.Sort(context).ToList();

        // Produkty z >=10 recenzji powinny być przed tymi z <10
        var firstProductAboveThreshold = result.FindIndex(p => p.ReviewCount >= 10);
        var lastProductBelowThreshold  = result.FindLastIndex(p => p.ReviewCount < 10);

        Assert.True(firstProductAboveThreshold < lastProductBelowThreshold,
            "Produkty z wystarczającą liczbą recenzji powinny być wyżej.");
    }

    [Fact]
    public void Sort_WhenAllProductsAboveThreshold_ShouldSortByRatingDescending()
    {
        var products = new List<Product>
        {
            new() { Name = "High", AverageRating = 4.8, ReviewCount = 50 },
            new() { Name = "Low",  AverageRating = 3.2, ReviewCount = 20 },
            new() { Name = "Mid",  AverageRating = 4.1, ReviewCount = 30 },
        };

        var result = _sut.Sort(new ProductSortContext(products)).ToList();

        Assert.Equal("High", result[0].Name);
        Assert.Equal("Mid",  result[1].Name);
        Assert.Equal("Low",  result[2].Name);
    }
}
public class ProductCatalogServiceTests
{
    [Fact]
    public async Task GetProductsAsync_WithUnknownSortKey_ShouldUseDefaultStrategy()
    {
        var strategies = new List<IProductSortStrategy>
        {
            new DefaultSortStrategy(),
            new PriceAscendingStrategy()
        };

        var mockRepo = Substitute.For<IProductRepository>();
        mockRepo.GetAsync(default, default).ReturnsForAnyArgs(
            Task.FromResult<IEnumerable<Product>>(
            [
                new() { Name = "A", Price = 100 },
                new() { Name = "B", Price = 50  }
            ]));

        var sut = new ProductCatalogService(
            strategies,
            mockRepo,
            NullLogger<ProductCatalogService>.Instance);

        var result = await sut.GetProductsAsync(new ProductQuery(SortBy: "unknown_sort"));

        // DefaultSortStrategy zachowuje kolejność — "A" nadal pierwszy
        Assert.Equal("A", result.First().Name);
    }
}

Rozszerzenie — strategia personalizowana

Za miesiąc przychodzi wymaganie: sortowanie spersonalizowane na podstawie historii zakupów użytkownika.

public class PersonalizedSortStrategy : IProductSortStrategy
{
    private readonly IRecommendationService _recommendations;

    public string SortKey => "personalized";

    public PersonalizedSortStrategy(IRecommendationService recommendations)
        => _recommendations = recommendations;

    public IOrderedEnumerable<Product> Sort(ProductSortContext context)
    {
        if (context.UserId is null)
            return context.Products.OrderBy(p => p.SalesCount); // fallback

        var scores = _recommendations.GetPersonalizedScores(
            context.UserId,
            context.Products.Select(p => p.Id));

        return context.Products
            .OrderByDescending(p => scores.GetValueOrDefault(p.Id, 0.0));
    }
}

Zmiany w istniejącym kodzie: zero. Dodajesz klasę i jedną linię rejestracji w DI:

builder.Services.AddScoped<IProductSortStrategy, PersonalizedSortStrategy>();

Kontroler, serwis, repozytorium, pozostałe strategie — nic się nie zmienia. To jest OCP w praktyce.


Zalety i kompromisy

Zyski:

  • Każda strategia testowalana niezależnie — bez potrzeby stawiania całego serwisu.
  • Dodanie nowego kryterium sortowania = nowa klasa + 1 linia DI.
  • Każda strategia może mieć własne zależności (IRecommendationService) bez obciążania kontekstu.
  • Łatwa konfiguracja A/B testów — dwie strategie popularity_v1 i popularity_v2.

Kompromisy:

  • Przy 2 kryteriach switch jest prostszy i wystarczający — nie wdrażaj Strategy “na zapas”.
  • Słownik strategii jest budowany w konstruktorze — jeśli dwie strategie mają ten sam SortKey, otrzymasz wyjątek przy starcie. Jest to celowe — błąd widoczny natychmiast, nie w runtime.
  • IOrderedEnumerable<T> nie jest IQueryable<T> — sortowanie dzieje się w pamięci, nie w bazie. Jeśli produktów jest 100k, pobierasz je wszystkie do pamięci przed sortowaniem. Dla zapytań do bazy rozważ przekazywanie Expression<Func<T, TKey>> zamiast IOrderedEnumerable.

Kiedy Strategy tu naprawdę warto — sygnały

  • Masz ≥3 wariantów algorytmu w jednej metodzie.
  • Warianty mają różne zależności zewnętrzne.
  • Chcesz testować każdy wariant niezależnie.
  • Spodziewasz się kolejnych wariantów w przyszłości (roadmapa to potwierdza).
  • Różne środowiska (dev/prod) potrzebują różnych implementacji.

Przy jednym lub dwóch wariantach — switch lub ternary są czytelniejsze i wystarczające.


Podsumowanie

Strategy w sortowaniu produktów to podręcznikowy przykład GoF: kontekst (ProductCatalogService) nie wie, który algorytm jest aktywny — wie tylko o interfejsie. Użytkownik wybiera algorytm przez parametr URL, serwis dopasowuje go przez słownik. Każda strategia jest izolowaną klasą z własnymi zależnościami i własnym zestawem testów.

Kluczowy insight: SortKey na interfejsie eliminuje zewnętrzną mapę string → typ. Strategie same deklarują, za co odpowiadają.


Następny post: Strategy Pattern w C# — Pipeline Walidacji przez Kompozycję Strategii — ten sam wzorzec, zupełnie inna perspektywa: nie jedna aktywna strategia z wielu, ale wiele strategii działających razem jako pipeline reguł walidacji.

👨‍💻
Mariusz Jurczenko
Senior .NET Developer · 10+ lat doświadczenia komercyjnego

Programista .NET z doświadczeniem komercyjnym w firmach takich jak NFZ, Kamsoft, Diagnostyka, Hermes Reply Polska czy Etisoft Smart Solutions. Twórca kursów, z których skorzystało już ponad 11 000 osób w Strefie Kursów i ponad 1 000 kursantów na dev-hobby.pl.

Specjalizacja: Clean Code, Clean Architecture i uczenie programowania tak, żeby dało się je naprawdę zrozumieć — nie wykuć.

🚀 Co dalej?

Zobacz to w praktyce na wideo i pobierz darmową roadmapę, żeby ułożyć naukę w spójną ścieżkę do pierwszej pracy.

czytanie to początek

Zamień wiedzę w umiejętności

Pobierz darmową Roadmapę .NET i ułóż takie tematy jak ten w spójną ścieżkę do pierwszej pracy.

Pobieram roadmapę →