Specification Pattern w C#

Specification Pattern w C# — jak pisać czysty kod i nigdy więcej nie szukać buga w 20 miejscach

Specification Pattern w C# — jak pisać czysty kod i nigdy więcej nie szukać buga w 20 miejscach

Piszesz

.Where(klient => klient.Wydal > 1000 && klient.KontoAktywne == true) 

w serwisie. Działa. Kopiujesz to do drugiego serwisu. Też działa.
Po pół roku masz ten sam warunek rozsiany w 20 plikach — i właśnie przyszedł szef ze zmianą progu z 1000 na 2000.
Zmieniasz ręcznie w 20 miejscach, o dwóch zapominasz i masz buga na produkcji.
Brzmi znajomo?


W tym artykule nauczysz się Specification Pattern

Pierwszego wzorca architektonicznego, który rozwiązuje ten problem raz na zawsze. Dowiesz się, jak działa pod maską Expression<Func<T, bool>>, jak zbudować elastyczne, kompozytowe zapytania bez eksplozji klas oraz jak wdrożyć standard branżowy używany w komercyjnych projektach .NET.

Spis treści


Problem: copy-paste LINQ niszczy Twoją architekturę

Wyobraź sobie taki scenariusz. Szef prosi o wyświetlenie “Wartościowych Klientów” na stronie głównej. Piszesz szybko

// HomeController.cs
var clients = await _context.Clients
    .Where(k => k.Wydal > 1000 && k.KontoAktywne == true)
    .ToListAsync();

Działa. Miesiąc później budujesz moduł newslettera:

// NewsletterService.cs
var clients = await _context.Clients
    .Where(k => k.Wydal > 1000 && k.KontoAktywne == true) // copy-paste
    .ToListAsync();

Pół roku i 20 plików później szef mówi: “Wartościowy klient to teraz taki, co wydał 2000, nie 1000.”
Musisz ręcznie przeszukać cały projekt. Zapomnisz o jednym miejscu, masz buga na produkcji.

Dlaczego to jest realny problem produkcyjny?

  • Zduplikowana logika biznesowa — jeden warunek żyje w wielu miejscach.
  • Brak nazwy k.Wydal > 1000 && k.KontoAktywne == true nic nie mówi czytającemu kod za 6 miesięcy.
  • Brak testowalności — nie możesz przetestować tego warunku izolowanie bez odpytywania bazy.
  • Krucha zmiana — jedna aktualizacja wymaga znajomości całej codebazy.

💡 Zasada DRY (Don’t Repeat Yourself)
mówi wprost: logika biznesowa powinna mieć jedno, autorytatywne źródło prawdy. Specification Pattern jest odpowiedzią na tę zasadę w warstwie zapytań.

Zobacz też: [Zasada Single Responsibility w praktyce C#] | [Repository Pattern krok po kroku]


Czym jest Specification Pattern?

Specification Pattern (Wzorzec Specyfikacji) to wzorzec z książki Domain-Driven Design Erica Evansa. Idea jest prosta:

Zamiast pisać warunek w każdym miejscu, w którym pobierasz dane — zamknij go w osobnej klasie z nazwą.

Tę klasę możesz sobie wyobrazić jako pudełko z etykietą:

┌─────────────────────────────────────┐
│  WartosciowyKlientSpecyfikacja      │  ← etykieta (nazwa klasy)
├─────────────────────────────────────┤
│  wydal > 1000 && aktywny == true    │  ← reguła (logika)
└─────────────────────────────────────┘

Od teraz żaden serwis nie pisze .Where(…) ręcznie. Każdy po prostu bierze to pudełko i podaje je do repozytorium:

var clients = await _repository.ListAsync(new WartosciowyKlientSpec());

Szef zmienia progi? Zmieniasz kod w jednym jedynym miejscu — wewnątrz pudełka.

Co zyskujesz?

CechaBez SpecificationZ Specification
Zmiana reguły20 miejsc w projekcie1 plik
Czytelność kodu.Where(k => k.Wydal > 1000…)new WartosciowyKlientSpec()
Unit testyWymaga bazy danychCzysta klasa C#, zero zależności
ReużywalnośćCopy-pasteRaz napisana, używana wszędzie

Klasa bazowa Specification<T>— rozkładamy na atomy

Żeby wzorzec działał, potrzebujemy uniwersalnego kształtu pudełka — abstrakcyjnej klasy bazowej. Zaczniemy od jej najprostszej wersji:

// Specification.cs
public abstract class Specification<T>
{
    // Przechowuje regułę LINQ jako DANE — nie wykonuje jej od razu!
    public Expression<Func<T, bool>>? Criteria { get; }

    protected Specification(Expression<Func<T, bool>>? criteria)
    {
        Criteria = criteria;
    }
}

Co to za dziwny typ: Expression<Func<T, bool>>?

To jest kluczowy element całego wzorca. Warto go zrozumieć raz, a dobrze.

Func<T, bool> — to zwykły delegate (funkcja) w C#. Przyjmuje obiekt typu T i zwraca bool. Wykonuje się natychmiast w pamięci RAM. Nie nadaje się do bazy danych.

Expression<Func<T, bool>> — to Drzewo Wyrażeń (Expression Tree). To nie jest gotowy kod do wykonania — to dane opisujące intencję.

Sygnał dla kompilatora:

Nie wykonuj tej lambdy teraz. Zapamiętaj jej strukturę jako obiekt, żeby Entity Framework mógł ją odczytać i przetłumaczyć na zapytanie SQL.
Dokładnie dlatego nasza klasa bazowa tylko przechowuje tę instrukcję, nie robi nic więcej. Reguła czeka na wywołanie przez repozytorium.

// ❌ Func — wykonuje się w pamięci, nie trafi do SQL
Func<Order, bool> func = o => o.TotalAmount >= 1000;

// ✅ Expression — EF Core przetłumaczy to na WHERE w SQL
Expression<Func<Order, bool>> expr = o => o.TotalAmount >= 1000;

Konkretna specyfikacja krok po kroku

Mając klasę bazową, tworzymy nasze konkretne “pudełka“. Każda klasa dziedziczy po Specification<T> i przekazuje regułę do konstruktora bazowego:

public class PremiumActiveOrdersSpec : Specification<Order>
{
    // Konstruktor przyjmuje próg kwotowy — specyfikacja jest parametryzowalna
    public PremiumActiveOrdersSpec(decimal minAmount)
        : base(o => o.TotalAmount >= minAmount && o.IsActive)
    {
    }
}

Co zyskujemy konkretnie?

1. Nazwa klasy = dokumentacja na żywo

// ❌ Co to znaczy?
.Where(o => o.TotalAmount >= 1000 && o.IsActive)

// ✅ Natychmiast wiadomo, co pobieramy
new PremiumActiveOrdersSpec(1000m)

2. Parametryzacja reguły

Próg możesz przekazać z konfiguracji lub z kontekstu użytkownika — bez zmiany logiki filtrowania.

3. Izolowana testowalność

// Unit test — zero Entity Frameworka, zero bazy danych
[Fact]
public void Should_Match_Active_Order_Above_Threshold()
{
    var spec = new PremiumActiveOrdersSpec(minAmount: 500m);

    var matchingOrder = new Order { TotalAmount = 800m, IsActive = true };
    var tooLow        = new Order { TotalAmount = 200m, IsActive = true };
    var inactive      = new Order { TotalAmount = 800m, IsActive = false };

    Assert.True(spec.Criteria!.Compile()(matchingOrder));
    Assert.False(spec.Criteria.Compile()(tooLow));
    Assert.False(spec.Criteria.Compile()(inactive));
}

💡 Metoda .Compile() zamienia Expression z powrotem w Func — tylko na potrzeby testów jednostkowych. W produkcji EF Core nigdy jej nie wywołuje.


Repozytorium jako strażnik architektury

Mamy pudełko z regułą. Teraz potrzebujemy kogoś, kto potrafi je otworzyć i przekazać do bazy danych. To zadanie repozytorium.

// OrderRepository.cs
public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public OrderRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<IReadOnlyList<Order>> ListAsync(Specification<Order> spec)
    {
        var query = _context.Orders.AsQueryable();

        if (spec.Criteria != null)
        {
            // Otwieramy pudełko i doklejamy regułę do EF Core
            query = query.Where(spec.Criteria);
        }

        // Serwis nigdy nie zobaczy bazy danych — tylko gotową listę
        return await query.ToListAsync();
    }
}

Co tu naprawdę się dzieje?

Serwis biznesowy
    │
    │  "Chcę zamówienia premium"
    │  new PremiumActiveOrdersSpec(1000m)
    ▼
Repozytorium
    │  otwiera pudełko (spec.Criteria)
    │  dokłada .Where() do IQueryable
    ▼
Entity Framework Core
    │  tłumaczy Expression na SQL
    ▼
SQL Server
    SELECT * FROM Orders
    WHERE TotalAmount >= 1000 AND IsActive = 1

Serwis jest całkowicie odcięty od warstwy danych.

Nie wie, czy pod spodem jest SQL Server, PostgreSQL, SQLite czy plik JSON. To jest zero wycieków abstrakcji jedna z fundamentalnych zasad Clean Architecture.

Interfejs repozytorium

W projekcie produkcyjnym repozytorium implementuje interfejs, to pozwala podmienić implementację w testach:

// IOrderRepository.cs
public interface IOrderRepository
{
    Task<IReadOnlyList<Order>> ListAsync(Specification<Order> spec);
    Task<Order?> FirstOrDefaultAsync(Specification<Order> spec);
}

Klocki Lego — kompozycja specyfikacji

To jest moment, w którym Specification Pattern pokazuje swoją prawdziwą moc.

Wyobraź sobie, że szef prosi: Pokaż mi zamówienia premium, ale tylko te złożone dzisiaj.

Pułapka: Class Explosion

Początkujący programista tworzy:

PremiumActiveOrdersSpec
PremiumActiveOrdersFromTodaySpec              // nowa klasa
PremiumActiveOrdersFromTodayForWarsawSpec     // jeszcze jedna
PremiumActiveOrdersFromTodayForWarsawWithDiscountSpec  // koszmar

To się nazywa Class Explosion — eksplozja klas. Zamiast czystej architektury dostajesz śmietnik.

Rozwiązanie: małe, niezależne klocki

Senior rozbija regułę na minimalne, ortogonalne specyfikacje:

// Klocek 1: tylko logika Premium
public class PremiumOrdersSpec : Specification<Order>
{
    public PremiumOrdersSpec(decimal minAmount)
        : base(o => o.TotalAmount >= minAmount) { }
}

// Klocek 2: tylko logika "z dzisiaj"
public class OrdersFromTodaySpec : Specification<Order>
{
    public OrdersFromTodaySpec()
        : base(o => o.OrderDate.Date == DateTime.Today) { }
}

Repozytorium przyjmujące wiele specyfikacji

Jedna drobna modyfikacja — słowo kluczowe params i repozytorium przyjmuje dowolną liczbę klocków:

// params pozwala przekazać 1, 2, 3... dowolnie wiele specyfikacji
public async Task<IReadOnlyList<Order>> ListAsync(params Specification<Order>[] specs)
{
    var query = _context.Orders.AsQueryable();

    foreach (var spec in specs)
    {
        if (spec.Criteria != null)
        {
            // Każdy klocek dokłada kolejny WHERE
            query = query.Where(spec.Criteria);
        }
    }

    return await query.ToListAsync();
}

Serwis biznesowy — tu dzieje się magia

// OrderService.cs
public async Task ProcessTodaysPremiumOrdersAsync()
{
    // Budujemy zapytanie z klocków — jak Lego
    var premiumSpec = new PremiumOrdersSpec(minAmount: 1000m);
    var todaySpec   = new OrdersFromTodaySpec();

    // Przekazujemy oba klocki — repozytorium zrobi resztę
    var orders = await _repository.ListAsync(premiumSpec, todaySpec);

    // ... dalsza logika biznesowa
}

Wygenerowany SQL — sprawdź co EF Core wyśle do bazy:

SELECT *
FROM Orders
WHERE TotalAmount >= 1000
AND CONVERT(date, OrderDate) = '2026-03-22'

EF Core sam połączył dwa .Where() operatorem AND. Jedno zapytanie, zero nadmiarowego kodu.

Potęga kompozycji w liczbach

Mając 10 małych specyfikacji, możesz zbudować tysiące kombinacji zapytań SQL bez dopisywania ani jednej nowej linijki w repozytorium czy serwisie.

// Przykładowe kombinacje z tych samych klocków:
await _repository.ListAsync(premiumSpec);
await _repository.ListAsync(todaySpec);
await _repository.ListAsync(premiumSpec, todaySpec);
await _repository.ListAsync(premiumSpec, todaySpec, warsawSpec);
await _repository.ListAsync(todaySpec, discountedSpec);
// ... i tak dalej

Ardalis.Specification — standard produkcyjny

Implementacja, którą zbudowaliśmy powyżej, to doskonały fundament do nauki mechaniki wzorca. W projektach komercyjnych jednak nie piszemy jej od zera.

Standardem branżowym jest pakiet NuGet autorstwa Steva Ardalis Smitha jednego z głównych architektów ASP.NET Core Guidance.

Instalacja

dotnet add package Ardalis.Specification
dotnet add package Ardalis.Specification.EntityFrameworkCore

Co zyskujesz ponad naszą wersję?

FunkcjaNasza implementacjaArdalis.Specification
Filtrowanie `.Where()`
Eager loading `.Include()`
Sortowanie `.OrderBy()`
Paginacja `.Skip().Take()`
Projekcje `.Select()`
Gotowe  `SpecificationEvaluator`
Wsparcie EF Core

 Przykład z Ardalis.Specification

using Ardalis.Specification;

// Specyfikacja z Include i sortowaniem — niemożliwe w naszej wersji
public class PremiumOrdersWithItemsSpec : Specification<Order>
{
    public PremiumOrdersWithItemsSpec(decimal minAmount)
    {
        Query
            .Where(o => o.TotalAmount >= minAmount && o.IsActive)
            .Include(o => o.Items)                 // eager loading powiązanych encji
            .OrderByDescending(o => o.TotalAmount) // sortowanie
            .Skip(0).Take(20);                     // paginacja
    }
}

💡 Ardalis.Specification
to de facto standard w ekosystemie Clean Architecture dla .NET. Używają go projekty oparte na [eShopOnWeb] — oficjalnym przykładzie Microsoftu.

Checklista i podsumowanie

✅ Checklista: wdrożenie Specification Pattern

  • – [ ] Utwórz klasę bazową Specification<T> z właściwością Expression<Func<T, bool>>? Criteria
  • – [ ] Dla każdej reguły biznesowej stwórz osobną klasę dziedziczącą po Specification<T>
  • – [ ] Nazwy klas kończ na Spec lub Specification (konwencja)
  • – [ ] Zmodyfikuj repozytorium tak, by przyjmowało params Specification<T>[]
  • – [ ] Napisz unit testy dla każdej specyfikacji (bez bazy danych!)
  • – [ ] W projekcie produkcyjnym — zainstaluj Ardalis.Specification
  • – [ ] Usuń gołe .Where() z warstwy serwisów i kontrolerów

Kluczowe pojęcia do zapamiętania

Expression<Func<T, bool>> — nie jest kodem do wykonania. To dane opisujące intencję lambdy, które EF Core potrafi przetłumaczyć na SQL.

Specification Pattern — wzorzec enkapsulujący logikę zapytania w named object. Jeden warunek biznesowy = jedna klasa = jedno miejsce zmiany.

Class Explosion — antywzorzec polegający na tworzeniu nowej klasy dla każdej kombinacji warunków. Rozwiązanie: małe, kompozytowe specyfikacje.

Repozytorium — strażnik architektury. Jedyne miejsce, które otwiera pudełko i przekazuje regułę do bazy danych.

Co dalej?

Specification Pattern to fundament. Gdy go opanujesz, naturalnymi kolejnymi krokami są:

  • CQRS — separacja zapytań od komend
  • MediatR — orchestracja logiki bez bezpośrednich zależności
  • Unit of Work — zarządzanie transakcjami ponad repozytoriami
  • Ardalis.Specification — pełna implementacja produkcyjna

🔗 Zobacz też

📣 Call To Action

👉 zostaw komentarz – co było dla Ciebie największym „aha momentem”
👉 udostępnij artykuł komuś, kto walczy z Func, Action

Zobacz także — powiązane artykuły

👉 MCP w .NET (C#) – jak zbudować serwer AI krok po kroku

👉 Tworzenie klas i obiektów w C# — kompletny przewodnik

👉 Pattern Matching w C# – switch expressions i type patterns

Dołącz do Listy VIP

I otrzymaj roadmapę Junior .NET Developer oraz najlepszą ofertę, gdy tylko ruszą zapisy!!!

Kontakt: mariuszjurczenko@dev-hobby.pl
Zero spamu. Możesz wypisać się w każdej chwili.

Dodaj komentarz