Strategy Pattern w C# — pipeline walidacji

Kąt widzenia: kompozycja wielu strategii, pipeline, async, Result Pattern
Przykład przewodni: system walidacji rejestracji użytkownika — każda reguła jako osobna strategia
Czym różni się ten post od poprzedniego?
W poprzednim poście (#110) jeden kontekst wybierał jedną aktywną strategię z wielu — jak przełącznik. Tylko jedna strategia sortowania działała w danym momencie.
Ten post pokazuje zupełnie inny wariant użycia Strategy: wiele strategii działa razem, jedna po drugiej, i każda decyduje o akceptacji lub odrzuceniu danych. To pipeline — i to jest użycie wzorca Strategy, którego większość tutoriali nie pokazuje.
Problem: walidacja danych rejestracji
Endpoint POST /auth/register przyjmuje:
public record RegisterRequest(
string Email,
string Username,
string Password);
Reguły walidacji:
- Email musi być poprawnym adresem.
- Email nie może być już zajęty w bazie.
- Username musi mieć 3–30 znaków i tylko
[a-zA-Z0-9_]. - Username nie może być zajęty.
- Hasło musi mieć min. 8 znaków, jedną dużą literę, jedną cyfrę.
- Hasło nie może być na liście popularnych haseł (rockyou list).
- Nowe konto z tej domeny email nie może być tworzone częściej niż 3 razy dziennie (rate limit).
Naiwna implementacja — wszystko w serwisie:
public async Task<Result> RegisterAsync(RegisterRequest request)
{
if (!IsValidEmail(request.Email))
return Result.Failure("Niepoprawny format email.");
if (await _userRepository.EmailExistsAsync(request.Email))
return Result.Failure("Email jest już zajęty.");
if (request.Username.Length < 3 || request.Username.Length > 30)
return Result.Failure("Username musi mieć 3-30 znaków.");
if (!Regex.IsMatch(request.Username, @"^[a-zA-Z0-9_]+$"))
return Result.Failure("Username może zawierać tylko litery, cyfry i podkreślnik.");
if (await _userRepository.UsernameExistsAsync(request.Username))
return Result.Failure("Username jest już zajęty.");
if (request.Password.Length < 8)
return Result.Failure("Hasło musi mieć min. 8 znaków.");
// ... i tak dalej przez 50 linii
}
Co jest złe:
- Metoda zna logikę każdej reguły — naruszenie SRP.
- Reguły z I/O (baza, zewnętrzne API) są wymieszane z regułami czysto lokalnymi — utrudnia testowanie.
- Dodanie nowej reguły = modyfikacja istniejącej metody — naruszenie OCP.
- Nie możesz łatwo wyłączyć jednej reguły dla testów.
Fundament: Result zamiast wyjątków
Zanim przejdziemy do strategii, wprowadzamy ValidationResult — wyjątki do walidacji to zły wzorzec (są kosztowne, nie są “exceptional” sytuacją).
public record ValidationResult
{
public bool IsValid { get; init; }
public string? ErrorCode { get; init; }
public string? ErrorMessage { get; init; }
public static ValidationResult Success() => new() { IsValid = true };
public static ValidationResult Failure(string errorCode, string errorMessage) =>
new() { IsValid = false, ErrorCode = errorCode, ErrorMessage = errorMessage };
}
Interfejs strategii walidacji
public interface IRegistrationValidationRule
{
// Kolejność wykonania — niższa liczba = wykonywane wcześniej
int Order { get; }
// Czy ta reguła wymaga I/O (baza, API)?
// Reguły synchroniczne wykonujemy pierwsze — tańsze
bool RequiresIO { get; }
Task<ValidationResult> ValidateAsync(RegisterRequest request);
}
Order i RequiresIO to metadane, które pipeline wykorzysta do optymalizacji kolejności — tanie reguły (regex, długość) przed drogimi (zapytania do bazy).
Reguły — konkretne strategie
Walidacja formatu email (czysto lokalna, tania)
public class EmailFormatRule : IRegistrationValidationRule
{
public int Order => 10;
public bool RequiresIO => false;
// Kompilacja regex raz przy starcie — nie w każdym wywołaniu
private static readonly Regex EmailPattern = new(
@"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public Task<ValidationResult> ValidateAsync(RegisterRequest request)
{
if (string.IsNullOrWhiteSpace(request.Email))
return Task.FromResult(
ValidationResult.Failure("EMAIL_REQUIRED", "Email jest wymagany."));
if (!EmailPattern.IsMatch(request.Email))
return Task.FromResult(
ValidationResult.Failure("EMAIL_INVALID_FORMAT", "Niepoprawny format adresu email."));
return Task.FromResult(ValidationResult.Success());
}
}
Walidacja formatu username (czysto lokalna, tania)
public class UsernameFormatRule : IRegistrationValidationRule
{
public int Order => 20;
public bool RequiresIO => false;
private static readonly Regex UsernamePattern = new(
@"^[a-zA-Z0-9_]{3,30}$",
RegexOptions.Compiled);
private static readonly HashSet<string> ReservedUsernames =
new(["admin", "root", "system", "support", "api"], StringComparer.OrdinalIgnoreCase);
public Task<ValidationResult> ValidateAsync(RegisterRequest request)
{
if (!UsernamePattern.IsMatch(request.Username))
return Task.FromResult(ValidationResult.Failure(
"USERNAME_INVALID_FORMAT",
"Username może zawierać 3-30 liter, cyfr i podkreślników."));
if (ReservedUsernames.Contains(request.Username))
return Task.FromResult(ValidationResult.Failure(
"USERNAME_RESERVED",
"Ta nazwa użytkownika jest zarezerwowana."));
return Task.FromResult(ValidationResult.Success());
}
}
Siła hasła (lokalna, ale z zewnętrzną listą popularnych haseł)
public class PasswordStrengthRule : IRegistrationValidationRule
{
public int Order => 30;
public bool RequiresIO => false;
// W produkcji: ładowana z pliku przy starcie, nie hardcoded
private readonly ICommonPasswordChecker _commonPasswords;
public PasswordStrengthRule(ICommonPasswordChecker commonPasswords)
=> _commonPasswords = commonPasswords;
public Task<ValidationResult> ValidateAsync(RegisterRequest request)
{
var pwd = request.Password;
if (pwd.Length < 8)
return Task.FromResult(ValidationResult.Failure(
"PASSWORD_TOO_SHORT",
"Hasło musi mieć co najmniej 8 znaków."));
if (!pwd.Any(char.IsUpper))
return Task.FromResult(ValidationResult.Failure(
"PASSWORD_NO_UPPERCASE",
"Hasło musi zawierać co najmniej jedną dużą literę."));
if (!pwd.Any(char.IsDigit))
return Task.FromResult(ValidationResult.Failure(
"PASSWORD_NO_DIGIT",
"Hasło musi zawierać co najmniej jedną cyfrę."));
if (_commonPasswords.IsCommon(pwd))
return Task.FromResult(ValidationResult.Failure(
"PASSWORD_TOO_COMMON",
"To hasło pojawia się na listach wycieków. Wybierz inne."));
return Task.FromResult(ValidationResult.Success());
}
}
Unikalność email (wymaga I/O)
public class EmailUniquenessRule : IRegistrationValidationRule
{
private readonly IUserRepository _userRepository;
public int Order => 50; // Wykonywana po regułach lokalnych
public bool RequiresIO => true;
public EmailUniquenessRule(IUserRepository userRepository)
=> _userRepository = userRepository;
public async Task<ValidationResult> ValidateAsync(RegisterRequest request)
{
var exists = await _userRepository.EmailExistsAsync(request.Email);
return exists
? ValidationResult.Failure("EMAIL_TAKEN", "Ten adres email jest już zajęty.")
: ValidationResult.Success();
}
}
Unikalność username (wymaga I/O)
public class UsernameUniquenessRule : IRegistrationValidationRule
{
private readonly IUserRepository _userRepository;
public int Order => 60;
public bool RequiresIO => true;
public UsernameUniquenessRule(IUserRepository userRepository)
=> _userRepository = userRepository;
public async Task<ValidationResult> ValidateAsync(RegisterRequest request)
{
var exists = await _userRepository.UsernameExistsAsync(request.Username);
return exists
? ValidationResult.Failure("USERNAME_TAKEN", "Ta nazwa użytkownika jest zajęta.")
: ValidationResult.Success();
}
}
Rate limiting rejestracji (wymaga I/O)
public class RegistrationRateLimitRule : IRegistrationValidationRule
{
private readonly IRegistrationRateLimiter _rateLimiter;
public int Order => 70;
public bool RequiresIO => true;
public RegistrationRateLimitRule(IRegistrationRateLimiter rateLimiter)
=> _rateLimiter = rateLimiter;
public async Task<ValidationResult> ValidateAsync(RegisterRequest request)
{
// Ekstrahujemy domenę email — limitujemy per domena, nie per adres
var domain = request.Email.Split('@').LastOrDefault() ?? string.Empty;
var isLimited = await _rateLimiter.IsLimitedAsync(domain, maxAttempts: 3, window: TimeSpan.FromDays(1));
return isLimited
? ValidationResult.Failure(
"REGISTRATION_RATE_LIMITED",
"Zbyt wiele rejestracji z tej domeny email. Spróbuj ponownie jutro.")
: ValidationResult.Success();
}
}
Pipeline — kompozytor strategii
public class RegistrationValidationPipeline
{
private readonly IReadOnlyList<IRegistrationValidationRule> _rules;
private readonly ILogger<RegistrationValidationPipeline> _logger;
public RegistrationValidationPipeline(
IEnumerable<IRegistrationValidationRule> rules,
ILogger<RegistrationValidationPipeline> logger)
{
// Sortujemy raz przy inicjalizacji:
// 1. Reguły bez I/O pierwsze (tańsze)
// 2. W obrębie grupy — według Order
_rules = rules
.OrderBy(r => r.RequiresIO)
.ThenBy(r => r.Order)
.ToList()
.AsReadOnly();
_logger = logger;
}
public async Task<IReadOnlyList<ValidationResult>> ValidateAsync(RegisterRequest request)
{
var errors = new List<ValidationResult>();
foreach (var rule in _rules)
{
var result = await rule.ValidateAsync(request);
if (!result.IsValid)
{
errors.Add(result);
// Short-circuit: jeśli reguła formatu email nie przeszła,
// nie ma sensu sprawdzać unikalności email w bazie
if (ShouldShortCircuit(rule, errors))
{
_logger.LogDebug(
"Walidacja przerwana po regule {Rule} — {ErrorCode}",
rule.GetType().Name, result.ErrorCode);
break;
}
}
}
return errors.AsReadOnly();
}
private static bool ShouldShortCircuit(
IRegistrationValidationRule failedRule,
List<ValidationResult> errors)
{
// Przerywamy po pierwszym błędzie reguły bez I/O —
// nie wysyłamy zapytań do bazy gdy dane są lokalnie nieprawidłowe.
// Dla reguł z I/O zbieramy wszystkie błędy razem.
return !failedRule.RequiresIO;
}
}
Kolejność wykonania w praktyce:
Order 10 → EmailFormatRule (lokalna, regex)
Order 20 → UsernameFormatRule (lokalna, regex + hashset)
Order 30 → PasswordStrengthRule (lokalna + common passwords)
--- jeśli powyższe OK, dopiero teraz trafiamy do bazy ---
Order 50 → EmailUniquenessRule (baza)
Order 60 → UsernameUniquenessRule (baza)
Order 70 → RegistrationRateLimitRule (Redis/cache)
Jeśli email ma zły format — natychmiast przerywamy. Nie wysyłamy zapytania do bazy o “czy ten śmieciowy string jest w bazie”.
Serwis rejestracji
public class RegistrationService
{
private readonly RegistrationValidationPipeline _pipeline;
private readonly IUserRepository _userRepository;
private readonly IPasswordHasher _passwordHasher;
private readonly ILogger<RegistrationService> _logger;
public RegistrationService(
RegistrationValidationPipeline pipeline,
IUserRepository userRepository,
IPasswordHasher passwordHasher,
ILogger<RegistrationService> logger)
{
_pipeline = pipeline;
_userRepository = userRepository;
_passwordHasher = passwordHasher;
_logger = logger;
}
public async Task<Result<Guid>> RegisterAsync(RegisterRequest request)
{
var validationErrors = await _pipeline.ValidateAsync(request);
if (validationErrors.Count > 0)
{
_logger.LogInformation(
"Rejestracja {Email} odrzucona: {ErrorCodes}",
request.Email,
string.Join(", ", validationErrors.Select(e => e.ErrorCode)));
return Result<Guid>.Failure(validationErrors);
}
var user = new User
{
Id = Guid.NewGuid(),
Email = request.Email.ToLowerInvariant(),
Username = request.Username,
PasswordHash = _passwordHasher.Hash(request.Password),
CreatedAt = DateTimeOffset.UtcNow
};
await _userRepository.AddAsync(user);
_logger.LogInformation("Użytkownik {UserId} zarejestrowany pomyślnie", user.Id);
return Result<Guid>.Success(user.Id);
}
}
Rejestracja w DI
// Program.cs
builder.Services.AddScoped<IRegistrationValidationRule, EmailFormatRule>();
builder.Services.AddScoped<IRegistrationValidationRule, UsernameFormatRule>();
builder.Services.AddScoped<IRegistrationValidationRule, PasswordStrengthRule>();
builder.Services.AddScoped<IRegistrationValidationRule, EmailUniquenessRule>();
builder.Services.AddScoped<IRegistrationValidationRule, UsernameUniquenessRule>();
builder.Services.AddScoped<IRegistrationValidationRule, RegistrationRateLimitRule>();
builder.Services.AddScoped<RegistrationValidationPipeline>();
builder.Services.AddScoped<RegistrationService>();
Unit testy — każda reguła niezależnie
public class EmailFormatRuleTests
{
private readonly EmailFormatRule _sut = new();
[Theory]
[InlineData("user@example.com", true)]
[InlineData("user+tag@domain.pl", true)]
[InlineData("notanemail", false)]
[InlineData("@domain.com", false)]
[InlineData("user@", false)]
[InlineData("", false)]
public async Task ValidateAsync_ShouldReturnExpected(string email, bool expectedValid)
{
var request = new RegisterRequest(email, "validuser", "ValidPass1");
var result = await _sut.ValidateAsync(request);
Assert.Equal(expectedValid, result.IsValid);
}
}
public class PasswordStrengthRuleTests
{
private readonly ICommonPasswordChecker _checker =
Substitute.For<ICommonPasswordChecker>();
[Fact]
public async Task ValidateAsync_WithCommonPassword_ShouldReturnFailure()
{
_checker.IsCommon("password123").Returns(true);
var sut = new PasswordStrengthRule(_checker);
var result = await sut.ValidateAsync(
new RegisterRequest("a@b.com", "user", "Password1"));
Assert.False(result.IsValid);
Assert.Equal("PASSWORD_TOO_COMMON", result.ErrorCode);
}
}
Testujesz EmailFormatRule bez bazy danych. Testujesz EmailUniquenessRule z zamockowanym IUserRepository. Pipeline testujesz z zamockowanymi regułami. Każda warstwa jest izolowana.
Null Object Pattern — reguła która zawsze przechodzi
W środowisku testowym możesz chcieć wyłączyć konkretną regułę, nie usuwając jej rejestracji z DI.
public class AlwaysPassRule : IRegistrationValidationRule
{
private readonly string _originalRuleType;
public AlwaysPassRule(string originalRuleType)
=> _originalRuleType = originalRuleType;
public int Order => 0;
public bool RequiresIO => false;
public Task<ValidationResult> ValidateAsync(RegisterRequest request)
{
// Loguje że regułę pominięto — przydatne w testach i staging
return Task.FromResult(ValidationResult.Success());
}
}
Albo prościej — w testach rejestrujesz własne mock-reguły zamiast prawdziwych:
// Test integration — wyłącz rate limiter
services.RemoveAll<IRegistrationValidationRule>();
services.AddScoped<IRegistrationValidationRule, EmailFormatRule>();
services.AddScoped<IRegistrationValidationRule, PasswordStrengthRule>();
// Nie rejestrujemy RegistrationRateLimitRule — nie jest potrzebna w teście
Porównanie z poprzednim postem
| Aspekt | Post #110 — Sortowanie | Post #111 — Walidacja |
|---|---|---|
| Ile strategii aktywnych | jedna z wielu | wszystkie naraz (pipeline) |
| Wybór strategii | przez klucz/parametr | kolejność (Order) |
| Short-circuit | nie dotyczy | tak — po lokalnym błędzie |
| Zależności strategii | proste / brak | różne (baza, cache, zewnętrzne API) |
| Wynik | posortowana kolekcja | lista błędów walidacji |
| Null Object | DefaultSortStrategy | AlwaysPassRule |
| Główna korzyść | łatwe dodawanie nowych sortowań | izolacja i niezależne testowanie reguł |
Kiedy ten wzorzec ma sens — sygnały
Masz wiele reguł walidacji w jednej klasie — każda reguła to kandydat na osobną strategię.
Reguły mają różne zależności (część jest lokalna, część wymaga I/O) — jako osobne klasy mogą mieć własne konstruktory.
Chcesz testować reguły niezależnie — niemożliwe gdy są metodami prywatnymi jednej klasy.
Reguły mogą być opcjonalne (feature flags, środowisko staging) — wystarczy nie rejestrować reguły w DI.
Kolejność walidacji ma znaczenie dla wydajności — sortowanie po RequiresIO i Order optymalizuje bez zmiany logiki.
Podsumowanie
Walidacja przez pipeline strategii to wzorzec używany w produkcyjnych systemach tam, gdzie reguł jest wiele, mają różną naturę (lokalne vs I/O) i muszą być testowalne niezależnie. Kluczowe elementy tego podejścia:
IRegistrationValidationRulezOrderiRequiresIO— metadane dla pipeline’u.RegistrationValidationPipeline— sortuje reguły, wykonuje je, zarządza short-circuit.Result<T>zamiast wyjątków — walidacja to normalny przepływ, nie wyjątkowa sytuacja.- Każda reguła ma własne zależności i własny test — izolacja na poziomie klasy.
Poprzedni post: Strategy Pattern w C# — Sortowanie Produktów z Wyborem Algorytmu w Runtime — klasyczny GoF: jeden kontekst, jedna aktywna strategia z wielu, wybór przez klucz z URL, pełna integracja z ASP.NET Core DI.
💡 Wolisz oglądać niż czytać? Sprawdź mój film na YouTube, który dokładnie omawia ten przykład!
Powiązane wzorce: zobacz szerszy przewodnik po wzorcach projektowych oraz pokrewny behawioralny wzorzec Command.
🚀 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ę →
1 comment