Specification Pattern w DDD – Enkapsulacja Logiki Biznesowej

TL;DR: Specification pattern enkapsuluje złożoną logikę biznesową w oddzielnych, testowalnych obiektach. W DDD używamy go do walidacji, filtrowania i implementacji business rules. Pozwala na kombinowanie specyfikacji za pomocą operatorów logicznych i czyni kod bardziej czytelnym.

Dlaczego Specification Pattern to Must-Have w DDD?

W Domain-Driven Design spotykamy się z coraz bardziej złożonymi regułami biznesowymi. Bez odpowiedniego wzorca, logika rozprasza się po całej aplikacji – w kontrolerach, serwisach, a nawet w modelach. Specification pattern rozwiązuje ten problem, tworząc dedykowane obiekty dla każdej reguły biznesowej. To nie tylko porządkuje kod, ale również czyni go testowalnym i ponownie używalnym.

Co się nauczysz:

  • Jak implementować Specification pattern w Javie 8+ z wykorzystaniem funkcji lambda
  • Tworzenie kompozytowych specyfikacji za pomocą operatorów AND, OR, NOT
  • Wykorzystanie specyfikacji do walidacji obiektów domenowych
  • Implementacja query specifications dla filtrowania danych
  • Integracja z JPA i Criteria API dla zaawansowanych zapytań
  • Best practices i anty-wzorce w używaniu specyfikacji
Wymagania wstępne: Znajomość Javy 8+, podstawy Domain-Driven Design, znajomość wzorców projektowych. Przydatna znajomość JPA/Hibernate dla przykładów z persistence.

Czym jest Specification Pattern?

Specification pattern to wzorzec behawioralny, który enkapsuluje logikę biznesową w obiektach specyfikacji. Każda specyfikacja reprezentuje jedną regułę biznesową i może być kombinowana z innymi specyfikacjami.

Specification – obiekt który enkapsuluje logikę sprawdzenia czy dany obiekt spełnia określone kryterium biznesowe. Zwraca true/false w zależności od wyniku sprawdzenia.
Specification to jak lista wymagań dla kandydata na stanowisko – każda specyfikacja sprawdza jedno kryterium (doświadczenie, wykształcenie, umiejętności), a na końcu kombinujemy je wszystkie aby podjąć decyzję.

Podstawowa implementacja w Javie

@FunctionalInterface
public interface Specification {
    boolean isSatisfiedBy(T candidate);
    
    default Specification and(Specification other) {
        return candidate -> this.isSatisfiedBy(candidate) && other.isSatisfiedBy(candidate);
    }
    
    default Specification or(Specification other) {
        return candidate -> this.isSatisfiedBy(candidate) || other.isSatisfiedBy(candidate);
    }
    
    default Specification not() {
        return candidate -> !this.isSatisfiedBy(candidate);
    }
}
Pro tip: Używaj @FunctionalInterface w Javie 8+ aby móc wykorzystać wyrażenia lambda do tworzenia prostych specyfikacji on-the-fly.

Implementacja Konkretnych Specyfikacji

Załóżmy, że mamy system e-commerce i chcemy zdefiniować reguły dla kwalifikowania klientów do różnych promocji:

// Model domenowy
public class Customer {
    private String email;
    private LocalDate registrationDate;
    private BigDecimal totalSpent;
    private CustomerType type;
    private boolean isActive;
    
    // konstruktor, gettery, settery
}

// Konkretne specyfikacje
public class ActiveCustomerSpecification implements Specification {
    @Override
    public boolean isSatisfiedBy(Customer customer) {
        return customer.isActive();
    }
}

public class LoyalCustomerSpecification implements Specification {
    private final int minimumMonths;
    private final BigDecimal minimumSpent;
    
    public LoyalCustomerSpecification(int minimumMonths, BigDecimal minimumSpent) {
        this.minimumMonths = minimumMonths;
        this.minimumSpent = minimumSpent;
    }
    
    @Override
    public boolean isSatisfiedBy(Customer customer) {
        LocalDate cutoffDate = LocalDate.now().minusMonths(minimumMonths);
        return customer.getRegistrationDate().isBefore(cutoffDate) 
               && customer.getTotalSpent().compareTo(minimumSpent) >= 0;
    }
}

public class PremiumCustomerSpecification implements Specification {
    @Override
    public boolean isSatisfiedBy(Customer customer) {
        return customer.getType() == CustomerType.PREMIUM;
    }
}

Kombinowanie Specyfikacji

Prawdziwa siła wzorca ujawnia się gdy kombinujemy specyfikacje:

public class CustomerPromotionService {
    
    // Specyfikacje jako stałe klasowe
    private static final Specification ACTIVE = new ActiveCustomerSpecification();
    private static final Specification LOYAL = 
        new LoyalCustomerSpecification(12, new BigDecimal("1000"));
    private static final Specification PREMIUM = new PremiumCustomerSpecification();
    
    public boolean isEligibleForVipPromotion(Customer customer) {
        Specification vipSpec = ACTIVE.and(LOYAL).and(PREMIUM);
        return vipSpec.isSatisfiedBy(customer);
    }
    
    public boolean isEligibleForLoyaltyDiscount(Customer customer) {
        Specification loyaltySpec = ACTIVE.and(LOYAL.or(PREMIUM));
        return loyaltySpec.isSatisfiedBy(customer);
    }
    
    public List findEligibleCustomers(List customers) {
        Specification eligibilitySpec = ACTIVE.and(LOYAL.or(PREMIUM));
        
        return customers.stream()
                       .filter(eligibilitySpec::isSatisfiedBy)
                       .collect(Collectors.toList());
    }
}

Kombinowanie specyfikacji za pomocą operatorów logicznych tworzy nowe specyfikacje. Każda kombinacja to osobny obiekt, który można testować niezależnie.

Specyfikacje z Parametrami

Często specyfikacje potrzebują parametrów konfiguracyjnych. Oto eleganckie rozwiązanie:

public class CustomerSpecifications {
    
    public static Specification spentMoreThan(BigDecimal amount) {
        return customer -> customer.getTotalSpent().compareTo(amount) > 0;
    }
    
    public static Specification registeredAfter(LocalDate date) {
        return customer -> customer.getRegistrationDate().isAfter(date);
    }
    
    public static Specification hasEmailDomain(String domain) {
        return customer -> customer.getEmail().endsWith("@" + domain);
    }
    
    public static Specification isOfType(CustomerType type) {
        return customer -> customer.getType() == type;
    }
}

// Użycie
public class CustomerService {
    
    public List findHighValueRecentCustomers() {
        Specification highValueRecent = 
            CustomerSpecifications.spentMoreThan(new BigDecimal("5000"))
                                 .and(CustomerSpecifications.registeredAfter(LocalDate.now().minusYears(1)));
        
        return customerRepository.findAll().stream()
                                 .filter(highValueRecent::isSatisfiedBy)
                                 .collect(Collectors.toList());
    }
}
Pro tip: Twórz factory methods dla specyfikacji z parametrami. To czyni kod bardziej czytelnym i pozwala na łatwe testowanie różnych scenariuszy.

Integracja z JPA i Criteria API

W aplikacjach enterprise często potrzebujemy przełożyć specyfikacje na zapytania bazodanowe:

// Rozszerzona specyfikacja dla JPA
public interface JpaSpecification extends Specification {
    Predicate toPredicate(Root root, CriteriaQuery query, CriteriaBuilder cb);
}

// Implementacja dla Customer
public class ActiveCustomerJpaSpecification implements JpaSpecification {
    
    @Override
    public boolean isSatisfiedBy(Customer customer) {
        return customer.isActive();
    }
    
    @Override
    public Predicate toPredicate(Root root, CriteriaQuery query, CriteriaBuilder cb) {
        return cb.isTrue(root.get("isActive"));
    }
}

// Repository z wsparciem dla specyfikacji
@Repository
public class CustomerRepository {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    public List findBySpecification(JpaSpecification specification) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery query = cb.createQuery(Customer.class);
        Root root = query.from(Customer.class);
        
        query.where(specification.toPredicate(root, query, cb));
        
        return entityManager.createQuery(query).getResultList();
    }
}
Uwaga: Specyfikacje JPA są bardziej skomplikowane w implementacji. Rozważ użycie Spring Data JPA Specifications które zapewniają gotowe rozwiązanie.

Spring Data JPA Specifications

Spring Data JPA oferuje built-in wsparcie dla Specification pattern:

// Repository dziedziczące po JpaSpecificationExecutor
public interface CustomerRepository extends JpaRepository, 
                                          JpaSpecificationExecutor {
}

// Specyfikacje jako klasa utility
public class CustomerSpecs {
    
    public static Specification isActive() {
        return (root, query, cb) -> cb.isTrue(root.get("isActive"));
    }
    
    public static Specification spentMoreThan(BigDecimal amount) {
        return (root, query, cb) -> cb.greaterThan(root.get("totalSpent"), amount);
    }
    
    public static Specification registeredAfter(LocalDate date) {
        return (root, query, cb) -> cb.greaterThan(root.get("registrationDate"), date);
    }
}

// Serwis używający specyfikacji
@Service
public class CustomerService {
    
    @Autowired
    private CustomerRepository customerRepository;
    
    public List findHighValueActiveCustomers(BigDecimal minimumSpent) {
        Specification spec = CustomerSpecs.isActive()
                                                   .and(CustomerSpecs.spentMoreThan(minimumSpent));
        
        return customerRepository.findAll(spec);
    }
}

Testowanie Specyfikacji

Jedną z największych zalet Specification pattern jest testowalność:

public class CustomerSpecificationTest {
    
    private Customer activeCustomer;
    private Customer inactiveCustomer;
    private Customer loyalCustomer;
    
    @BeforeEach
    void setUp() {
        activeCustomer = new Customer("active@test.com", LocalDate.now().minusYears(2), 
                                    new BigDecimal("2000"), CustomerType.REGULAR, true);
        inactiveCustomer = new Customer("inactive@test.com", LocalDate.now().minusMonths(6), 
                                      new BigDecimal("500"), CustomerType.REGULAR, false);
        loyalCustomer = new Customer("loyal@test.com", LocalDate.now().minusYears(3), 
                                   new BigDecimal("5000"), CustomerType.PREMIUM, true);
    }
    
    @Test
    void activeCustomerSpecification_shouldReturnTrueForActiveCustomer() {
        Specification spec = new ActiveCustomerSpecification();
        
        assertTrue(spec.isSatisfiedBy(activeCustomer));
        assertFalse(spec.isSatisfiedBy(inactiveCustomer));
    }
    
    @Test
    void combinedSpecification_shouldWorkCorrectly() {
        Specification activeAndLoyal = 
            new ActiveCustomerSpecification()
                .and(new LoyalCustomerSpecification(12, new BigDecimal("1000")));
        
        assertTrue(activeAndLoyal.isSatisfiedBy(loyalCustomer));
        assertFalse(activeAndLoyal.isSatisfiedBy(inactiveCustomer));
        assertFalse(activeAndLoyal.isSatisfiedBy(activeCustomer)); // not loyal enough
    }
}
Pro tip: Testuj każdą specyfikację osobno oraz wszystkie możliwe kombinacje. To jest kluczowa zaleta wzorca – każda reguła biznesowa ma dedykowane testy.

Najlepsze Praktyki i Anty-wzorce

Dobre praktyki:

  • Jedna specyfikacja = jedna reguła biznesowa
  • Używaj immutable objects dla specyfikacji
  • Twórz factory methods dla specyfikacji z parametrami
  • Kombinuj specyfikacje za pomocą operatorów logicznych
Pułapka: Nie twórz specyfikacji które są wrapperami dla prostych getterów. customer.isActive() nie potrzebuje specyfikacji. Używaj wzorca dla złożonej logiki biznesowej.
✅ Dobre praktyki❌ Anty-wzorce
Enkapsulacja złożonej logikiSpecyfikacje dla prostych getterów
Testowalne reguły biznesoweSpecyfikacje z side effects
Kombinowanie za pomocą AND/ORMutowalne specyfikacje
Factory methods z parametramiSpecyfikacje ze stanem
Kiedy używać Specification pattern zamiast prostych if-ów?

Gdy masz złożoną logikę biznesową, która może się zmieniać, jest używana w wielu miejscach, lub potrzebujesz kombinować różne reguły. Proste sprawdzenia typu if (customer.isActive()) nie wymagają specyfikacji.

Czy mogę używać specyfikacji do walidacji?

Tak! Specyfikacje są idealne do walidacji obiektów domenowych. Możesz kombinować różne reguły walidacyjne i tworzyć złożone scenariusze sprawdzania poprawności danych.

Jak specyfikacje wpływają na wydajność?

W pamięci – minimalny wpływ. Dla dużych zbiorów danych lepiej używać JPA Specifications które tłumaczą się na zapytania SQL zamiast filtrować w Javie.

Czy mogę cachować specyfikacje?

Tak, ale tylko jeśli są immutable i bezstanowe. Najlepiej tworzyć je jako statyczne stałe lub używać factory methods.

Jak debugować skomplikowane kombinacje specyfikacji?

Dodaj metody toString() do specyfikacji opisujące ich logikę. Testuj każdą specyfikację osobno przed kombinowaniem.

🚀 Zadanie dla Ciebie

Stwórz system specyfikacji dla systemu zarządzania zamówieniami:

  • Specyfikacja dla zamówień o wysokiej wartości (powyżej 1000 PLN)
  • Specyfikacja dla zamówień pilnych (dostawa w ciągu 24h)
  • Specyfikacja dla zamówień od klientów VIP
  • Kombinacja specyfikacji dla zamówień wymagających special handling

Napisz testy jednostkowe i zintegruj z Spring Data JPA. Bonus: dodaj specyfikację dla zamówień w określonym przedziale czasowym.

Przydatne zasoby:

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Przewijanie do góry