🔥 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# — 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:

  1. Email musi być poprawnym adresem.
  2. Email nie może być już zajęty w bazie.
  3. Username musi mieć 3–30 znaków i tylko [a-zA-Z0-9_].
  4. Username nie może być zajęty.
  5. Hasło musi mieć min. 8 znaków, jedną dużą literę, jedną cyfrę.
  6. Hasło nie może być na liście popularnych haseł (rockyou list).
  7. 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

AspektPost #110 — SortowaniePost #111 — Walidacja
Ile strategii aktywnychjedna z wieluwszystkie naraz (pipeline)
Wybór strategiiprzez klucz/parametrkolejność (Order)
Short-circuitnie dotyczytak — po lokalnym błędzie
Zależności strategiiproste / brakróżne (baza, cache, zewnętrzne API)
Wynikposortowana kolekcjalista błędów walidacji
Null ObjectDefaultSortStrategyAlwaysPassRule
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:

  • IRegistrationValidationRule z Order i RequiresIO — 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.

👨‍💻
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ę →