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_v1ipopularity_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 jestIQueryable<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ż przekazywanieExpression<Func<T, TKey>>zamiastIOrderedEnumerable.
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.
🚀 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.
- 🗺️ Pobierz darmową roadmapę Junior .NET Developer — 12 kroków od podstaw C# do pierwszej pracy: dev-hobby.pl
- 🎬 Subskrybuj kanał YouTube — nowe filmy co tydzień.
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ę →
3 comments