Robocikowo>ROBOCIKOWO
Wzorzec architektoniczny

Wzorzec specyfikacji (Specification) — co to jest i jak działa?

Wzorzec specyfikacji (Specification) — co to jest i jak działa?

Wzorzec specyfikacji pozwala zapisać regułę biznesową jako osobny, testowalny obiekt, który odpowiada na jedno pytanie: „czy ten obiekt spełnia warunek?". Warto go znać, bo porządkuje logikę rozsianą po całym kodzie i pozwala łączyć reguły jak klocki.

Czym jest specyfikacja?

Specyfikacja (ang. Specification) to wzorzec projektowy spopularyzowany przez projektowanie sterowane dziedziną (Domain-Driven Design). Dziś funkcjonuje jednak znacznie szerzej niż samo DDD — jego odmiany spotkamy w Spring Data, NHibernate, Entity Framework Core, QueryDSL czy w bibliotece Ardalis.Specification. Wspólna idea jest jedna: zamknąć pojedynczą regułę biznesową w osobnym obiekcie, który potrafi ocenić, czy dany kandydat — klient, zamówienie, faktura — spełnia określony warunek.

W klasycznej postaci taki obiekt udostępnia metodę isSatisfiedBy(kandydat) zwracającą true albo false, choć współczesne implementacje często reprezentują specyfikację inaczej (wrócimy do tego niżej). Kluczowe jest to, że reguła przestaje być fragmentem instrukcji warunkowej, a staje się nazwanym, samodzielnym bytem, który można przekazywać jako argument, przechowywać w kolekcji i testować w izolacji.

Warto od razu zaznaczyć, czym specyfikacja nie jest. To nie silnik reguł (rule engine), nie system walidacji formularzy ani zapytanie do bazy danych. To niewielki, taktyczny wzorzec obiektowy — cegiełka porządkująca logikę decyzyjną w warstwie dziedziny. Jak podaje anglojęzyczna Wikipedia, całość sprowadza się do tego, że reguły biznesowe da się rekombinować, łącząc je operatorami logiki Boole'a.

Jaki problem rozwiązuje?

W typowym kodzie reguła biznesowa istnieje jako goły warunek techniczny. „Klient premium" to gdzieś saldo > 100, „faktura przeterminowana" to dni > 30, a „transakcja podejrzana" to splątany if z kilkoma polami. Ten sam warunek bywa powielony w kontrolerze, serwisie, zapytaniu do bazy i teście — a jego biznesowy sens jest ukryty za liczbą, którą trzeba dopiero rozszyfrować.

Specyfikacja odpowiada na to jednym ruchem: każe opisać fakt biznesowy, a nie warunek techniczny. Zamiast cena > 100 powstaje KlientPremiumSpecification; zamiast dni > 30FakturaPrzeterminowanaSpecification. To nie kosmetyka. Kod zaczyna nazywać rzeczy tak, jak nazywa je biznes, a definicja reguły trafia w jedno, autorytatywne miejsce. To właśnie esencja DDD — model dziedziny mówi o pojęciach, nie o progach liczbowych.

Kto za tym stoi?

Wzorzec spopularyzował Eric Evans w książce Domain-Driven Design (2003), a następnie rozwinął go wspólnie z Martinem Fowlerem w opracowaniu poświęconym specyfikacjom. To dwa najważniejsze nazwiska w środowisku projektowania obiektowego: Evans jest autorem koncepcji DDD, Fowler — jednym z najczęściej cytowanych autorów w tematyce wzorców i refaktoryzacji. Praktycznych omówień doczekali się później inni, m.in. Vladimir Khorikov z serwisu Enterprise Craftsmanship oraz baza wiedzy DevIQ.

Jak to działa?

Mechanizm jest prosty. Definiujemy wspólny kontrakt (np. interfejs ISpecification) z metodą oceniającą kandydata, a każda konkretna reguła to osobna klasa implementująca ten kontrakt. Kod podejmujący decyzję nie zna szczegółów reguły — pyta jedynie: „czy ten obiekt cię spełnia?".

Java
interface Specification<T> {                 // wspólny kontrakt
    boolean isSatisfiedBy(T candidate);
}

class InvoiceOverdue implements Specification<Invoice> {
    public boolean isSatisfiedBy(Invoice i) {  // konkretna reguła
        return i.getDaysLate() > 30;
    }
}

// użycie: kod decydujący pyta tylko, czy reguła jest spełniona
Specification<Invoice> overdue = new InvoiceOverdue();
if (overdue.isSatisfiedBy(invoice)) {
    sendReminder(invoice);
}

W praktyce API bywa różne. W Spring Data typ Specification<Customer> nie ma metody isSatisfiedBy(), lecz toPredicate(...), która buduje fragment zapytania Criteria API. Z kolei Ardalis.Specification nie udostępnia żadnej z tych metod, opisując kryteria deklaratywnie. Mimo różnic idea pozostaje ta sama: nazwana reguła zamknięta w obiekcie.

Java
import org.springframework.data.jpa.domain.Specification;
import jakarta.persistence.criteria.*;

// Spring Data: brak isSatisfiedBy(), jest toPredicate(...) budujące fragment zapytania
class PremiumCustomerSpec implements Specification<Customer> {
    public Predicate toPredicate(Root<Customer> root, CriteriaQuery<?> q, CriteriaBuilder cb) {
        return cb.greaterThan(root.get("balance"), 100);
    }
}

// użycie: repozytorium tłumaczy specyfikację na zapytanie SQL
List<Customer> premium = customerRepository.findAll(new PremiumCustomerSpec());

// Ardalis.Specification (C#) opisuje kryteria deklaratywnie — bez żadnej z tych metod

Prawdziwa wygoda pojawia się przy łączeniu reguł. Specyfikacje komponuje się operatorami AND (oba warunki), OR (wystarczy jeden) i NOT (zaprzeczenie). Ze specyfikacji „faktura przeterminowana", „wysłano wezwania" oraz zaprzeczenia „już w windykacji" powstaje jedna reguła: przeterminowana AND wysłano wezwania AND NOT w windykacji — złożona z gotowych klocków, bez pisania nowego, splątanego warunku.

Java
// Aby overdue miało and()/or()/not(), interfejs Specification
// musi je udostępniać — najprościej jako metody domyślne (default):
interface Specification<T> {
    boolean isSatisfiedBy(T candidate);

    default Specification<T> and(Specification<T> o) { return c -> isSatisfiedBy(c) && o.isSatisfiedBy(c); }
    default Specification<T> or (Specification<T> o) { return c -> isSatisfiedBy(c) || o.isSatisfiedBy(c); }
    default Specification<T> not()                   { return c -> !isSatisfiedBy(c); }
}

// wtedy każda reguła — nawet lambda — od razu się składa:
Specification<Invoice> overdue       = i -> i.getDaysLate() > 30;
Specification<Invoice> noticesSent   = i -> i.noticesSent();
Specification<Invoice> inCollections = i -> i.inCollections();

// Kompozycja operatorami AND / OR / NOT:
Specification<Invoice> doWindykacji =
        overdue
            .and(noticesSent)
            .and(inCollections.not());   // przeterminowana AND wezwania AND NOT w windykacji

boolean wyslij = doWindykacji.isSatisfiedBy(invoice);

Sama kompozycja nie jest już jednak wyłączną cechą specyfikacji: w wielu językach zwykły predykat (np. Predicate<Customer>) też potrafi się składać przez and, or i negate. Specyfikacja idzie krok dalej — nadaje regule nazwę, miejsce w modelu dziedziny i możliwość wielokrotnego użycia. I to jest jej rzeczywista przewaga, a nie same operatory logiczne.

Java
// Zwykły predykat TEŻ się składa:
Predicate<Customer> premium = c -> c.getBalance() > 100;
Predicate<Customer> active  = Customer::isActive;
Predicate<Customer> target  = premium.and(active).or(vip).negate();
// ...ale predykat nie ma nazwy ani miejsca w modelu dziedziny — specyfikacja ma

Z jakich elementów się składa?

Klasyczna implementacja opiera się na trzech warstwach:

  • Interfejs specyfikacji — kontrakt z metodą oceniającą kandydata.
  • Abstrakcyjna specyfikacja złożona (composite specification) z metodami And, Or i Not. Dziedziczą po niej wszystkie konkretne reguły, dzięki czemu każda od razu umie się łączyć z innymi.
  • Konkretne specyfikacje — pojedyncze reguły, każda odpowiadająca jednemu, jasno nazwanemu warunkowi.

Dochodzą do tego klasy operatorów (AndSpecification, OrSpecification, NotSpecification), przechowujące łączone reguły i wywołujące je po kolei — klasyczny wzorzec kompozytu.

To jednak tylko jedna z możliwych postaci, nie jedyna słuszna. Specyfikacja równie dobrze może być rekordem, sealed interface, lambdą, funkcją czy wartością enuma. Istotny jest kontrakt — ocena kandydata i możliwość składania reguł — a nie konkretny szkielet klas.

Do czego może być używane?

Evans i Fowler wskazali trzy zastosowania, które do dziś porządkują myślenie o wzorcu:

  • Walidacja — sprawdzenie, czy istniejący obiekt spełnia kryterium (np. czy zamówienie kwalifikuje się do darmowej wysyłki).
  • Selekcja — wybór z kolekcji lub bazy obiektów pasujących do reguły.
  • Budowanie na zamówienie — tworzenie nowego obiektu tak, aby od razu spełniał zadaną specyfikację.

Jak zauważa Khorikov, w praktyce dominują dwa pierwsze przypadki, a największą wartością bywa eliminacja duplikacji wiedzy dziedzinowej. Bez wzorca definicja np. „filmu dla dzieci" potrafi żyć w dwóch miejscach: raz jako walidacja w pamięci, raz jako warunek w zapytaniu do bazy. Specyfikacja zbiera tę wiedzę w jednym źródle prawdy.

Czym różni się od innych rozwiązań?

Specyfikację najlepiej widać w zestawieniu z tym, co próbuje zastąpić:

PodejścieCzym jestGłówna różnica wobec specyfikacji
Zwykły warunek ifwarunek w miejscu użyciatrudny do ponownego użycia i testowania w izolacji
Predykat / lambdafunkcja zwracająca prawda lub fałszteż się składa, ale nie zbiera wiedzy dziedzinowej w jednym nazwanym miejscu
Wzorzec strategiiwybór sposobu wykonania czynnościodpowiada „jak", nie „tak lub nie"
Silnik regułrozbudowany system z własnym językiemznacznie cięższy, działa poza kodem aplikacji
Najbliżej jest predykat — jak podkreśla Khorikov, luźne wyrażenia predykatowe nie gromadzą jednak wiedzy dziedzinowej w jednym nazwanym miejscu, a silnie typowane specyfikacje robią to lepiej.

Najważniejsze ograniczenia i wyzwania

Największe ryzyko to nadmierna inżynieria. Dla prostej aplikacji z kilkoma warunkami osobna klasa na każdą regułę to niepotrzebny narzut i mnożenie plików. Wzorzec zwraca się tam, gdzie reguł jest wiele, bywają łączone i powtarzają się w różnych miejscach systemu.

Drugie, poważniejsze wyzwanie to przełożenie specyfikacji na zapytanie do bazy danych. Reguła działająca w pamięci na pojedynczym obiekcie nie zawsze zamienia się prosto na warunek SQL po stronie bazy. Pobranie wszystkich rekordów do pamięci i dopiero potem filtrowanie bywa katastrofalne wydajnościowo. Dlatego dojrzałe implementacje (np. w .NET z użyciem drzew wyrażeń) budują specyfikację tak, by dało się ją przetłumaczyć na zapytanie — co komplikuje kod. Khorikov przestrzega też przed zwracaniem „surowego" IQueryable z repozytorium, wskazując na naruszenia zasad DRY i podstawienia Liskov.

Kiedy nie używać specyfikacji?

Wzorzec nie jest domyślnym wyborem do każdej reguły. Lepiej go pominąć, gdy:

  • reguła jest używana tylko raz, w jednym miejscu,
  • wystarcza czytelny, prosty if,
  • nie ma potrzeby ponownego użycia ani wersji negowanej,
  • reguł nie trzeba ze sobą łączyć,
  • projekt to zwykły CRUD bez wyodrębnionego modelu dziedziny.

To ważny bezpiecznik: łatwo popaść w zachwyt i zacząć pisać 300 klas specyfikacji tam, gdzie w zupełności starczyłby jeden warunek. Specyfikacja spłaca się dopiero przy realnej powtarzalności i kompozycji reguł.

Dlaczego to jest istotne?

Wzorzec specyfikacji dotyka jednego z najtrudniejszych problemów w utrzymaniu oprogramowania: rozproszenia wiedzy o regułach biznesowych. W typowym systemie ten sam warunek — „aktywny klient premium", „zamówienie do zwrotu", „transakcja podejrzana" — pojawia się w kontrolerze, serwisie, zapytaniu do bazy i teście. Gdy reguła się zmienia, trzeba ją znaleźć i poprawić w każdym z tych miejsc, a pominięcie choćby jednego to gotowy błąd produkcyjny.

Specyfikacja daje na to konkretną odpowiedź: reguła staje się jednym, nazwanym obiektem — jedynym źródłem prawdy — i przenosi logikę biznesową z technicznych zakamarków kodu do wyraźnie zarysowanej warstwy dziedziny. Kod zaczyna mówić językiem biznesu: zamiast splątanego warunku widzimy nowaSpecyfikacja.And(inna), co czyta się niemal jak zdanie. To nie wzorzec, który „zmienia wszystko" — to precyzyjne narzędzie do konkretnego problemu, którego wartość najłatwiej docenić, gdy zabraknie go w rozrastającym się projekcie.

Warto przy tym pamiętać o niuansie: gdy framework „udostępnia specyfikacje", nie zawsze chodzi o klasyczny wzorzec. Mechanizm specyfikacji w Spring Data JPA to raczej implementacja inspirowana wzorcem, skoncentrowana na budowaniu zapytań przez Criteria API — bliska idei, ale nie tożsama z klasyczną specyfikacją opartą na isSatisfiedBy.

Ostatecznie specyfikacja nie naprawi architektury sama z siebie. Ale w rękach zespołu, który świadomie zarządza regułami biznesowymi, zamienia chaos rozsianych warunków w uporządkowany, testowalny i czytelny zbiór klocków — pod warunkiem, że sięga się po nią tam, gdzie naprawdę jest potrzebna.

Źródła

  • Wikipedia — Specification pattern — link
  • Enterprise Craftsmanship (Vladimir Khorikov) — Specification Pattern: C# implementation — link
  • DevIQ — Specification Pattern — link
  • Spring Data JPA — Specifications (dokumentacja) — link
Udostępnij to opracowanie