PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

Wzorzec Specification – enkapsulacja reguł biznesowych, AND/OR/NOT kompozycja, testowanie

by Henryk Tews / wtorek, 04 marca 2025 / Opublikowano w Wzorce projektowe

Specification to wzorzec który pozwala enkapsulować reguły biznesowe w osobnych, komposytowalnych obiektach. Zamiast rosnącego if-elsa w serwisie albo powtarzającego się kodu filtrowania w kilku miejscach, każda reguła to osobna klasa którą można łączyć przez AND, OR i NOT. Świetnie sprawdza się przy filtrach produktów, regułach rabatowych i walidacji zamówień.

Problem bez Specification

<?php

// Reguły kwalifikacji do rabatu rozproszone po kodzie
class OrderDiscountService
{
    public function getDiscount(Order $order, Customer $customer): float
    {
        // Warunki wymieszane z logiką obliczania rabatu
        if (
            $order->getTotal() >= 500.0
            && $customer->getOrderCount() >= 5
            && !$order->hasSpecialItems()
            && $customer->isVerified()
            && $order->getCurrency() === 'PLN'
        ) {
            return 0.15; // 15% rabatu
        }

        if ($order->getTotal() >= 200.0 && $customer->isVerified()) {
            return 0.05; // 5% rabatu
        }

        return 0.0;
    }
}

// Te same warunki powtarzają się w innym miejscu
class OrderEligibilityChecker
{
    public function isPremiumOrder(Order $order, Customer $customer): bool
    {
        // Kopiuj-wklej z powyżej - klasyczny zapach kodu
        return $order->getTotal() >= 500.0
            && $customer->getOrderCount() >= 5
            && !$order->hasSpecialItems()
            && $customer->isVerified();
    }
}

Interfejs Specification

<?php

declare(strict_types=1);

// Bazowy interfejs - jeden kontrakt
interface SpecificationInterface
{
    public function isSatisfiedBy(mixed $candidate): bool;
}

// Abstrakcyjna klasa bazowa z metodami kombinowania
abstract class AbstractSpecification implements SpecificationInterface
{
    // Logiczne AND - oba warunki muszą być spełnione
    public function and(SpecificationInterface $other): AndSpecification
    {
        return new AndSpecification($this, $other);
    }

    // Logiczne OR - przynajmniej jeden warunek musi być spełniony
    public function or(SpecificationInterface $other): OrSpecification
    {
        return new OrSpecification($this, $other);
    }

    // Logiczne NOT - negacja
    public function not(): NotSpecification
    {
        return new NotSpecification($this);
    }
}

// Composite specifications - łączniki
final class AndSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly SpecificationInterface $left,
        private readonly SpecificationInterface $right
    ) {}

    public function isSatisfiedBy(mixed $candidate): bool
    {
        return $this->left->isSatisfiedBy($candidate)
            && $this->right->isSatisfiedBy($candidate);
    }
}

final class OrSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly SpecificationInterface $left,
        private readonly SpecificationInterface $right
    ) {}

    public function isSatisfiedBy(mixed $candidate): bool
    {
        return $this->left->isSatisfiedBy($candidate)
            || $this->right->isSatisfiedBy($candidate);
    }
}

final class NotSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly SpecificationInterface $wrapped
    ) {}

    public function isSatisfiedBy(mixed $candidate): bool
    {
        return !$this->wrapped->isSatisfiedBy($candidate);
    }
}

Konkretne Specyfikacje dla zamówień

<?php

declare(strict_types=1);

// Każda reguła = osobna klasa, jeden obowiązek
final class MinimumOrderValueSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly float $minimumValue
    ) {}

    public function isSatisfiedBy(mixed $candidate): bool
    {
        /** @var array{order: Order, customer: Customer} $candidate */
        return $candidate['order']->getTotal() >= $this->minimumValue;
    }
}

final class VerifiedCustomerSpecification extends AbstractSpecification
{
    public function isSatisfiedBy(mixed $candidate): bool
    {
        return $candidate['customer']->isVerified();
    }
}

final class MinimumOrderCountSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly int $minimumCount
    ) {}

    public function isSatisfiedBy(mixed $candidate): bool
    {
        return $candidate['customer']->getOrderCount() >= $this->minimumCount;
    }
}

final class HasNoSpecialItemsSpecification extends AbstractSpecification
{
    public function isSatisfiedBy(mixed $candidate): bool
    {
        return !$candidate['order']->hasSpecialItems();
    }
}

final class OrderCurrencySpecification extends AbstractSpecification
{
    public function __construct(
        private readonly string $currency
    ) {}

    public function isSatisfiedBy(mixed $candidate): bool
    {
        return $candidate['order']->getCurrency() === $this->currency;
    }
}

Składanie reguł i użycie w serwisie

<?php

declare(strict_types=1);

class OrderDiscountService
{
    // Speficykacje zdefiniowane raz, używane wielokrotnie
    private SpecificationInterface $premiumDiscountSpec;
    private SpecificationInterface $standardDiscountSpec;

    public function __construct()
    {
        // Reguła premium: min 500 PLN + zweryfikowany + 5+ zamówień + brak specjalnych
        $this->premiumDiscountSpec = (new MinimumOrderValueSpecification(500.0))
            ->and(new VerifiedCustomerSpecification())
            ->and(new MinimumOrderCountSpecification(5))
            ->and(new HasNoSpecialItemsSpecification())
            ->and(new OrderCurrencySpecification('PLN'));

        // Reguła standard: min 200 PLN + zweryfikowany
        $this->standardDiscountSpec = (new MinimumOrderValueSpecification(200.0))
            ->and(new VerifiedCustomerSpecification());
    }

    public function getDiscount(Order $order, Customer $customer): float
    {
        $context = ['order' => $order, 'customer' => $customer];

        if ($this->premiumDiscountSpec->isSatisfiedBy($context)) {
            return 0.15;
        }

        if ($this->standardDiscountSpec->isSatisfiedBy($context)) {
            return 0.05;
        }

        return 0.0;
    }

    // Ta sama reguła używana gdzie indziej - zero duplikacji
    public function isPremiumOrder(Order $order, Customer $customer): bool
    {
        return $this->premiumDiscountSpec->isSatisfiedBy(
            ['order' => $order, 'customer' => $customer]
        );
    }
}

Specification z filtrowanie kolekcji

<?php

declare(strict_types=1);

// Specifikacje dla filtrowania produktów
final class InStockSpecification extends AbstractSpecification
{
    public function isSatisfiedBy(mixed $product): bool
    {
        return $product->getStockQty() > 0;
    }
}

final class PriceRangeSpecification extends AbstractSpecification
{
    public function __construct(
        private readonly float $min,
        private readonly float $max
    ) {}

    public function isSatisfiedBy(mixed $product): bool
    {
        $price = $product->getPrice();
        return $price >= $this->min && $price <= $this->max;
    }
}

final class CategorySpecification extends AbstractSpecification
{
    public function __construct(
        private readonly array $categoryIds
    ) {}

    public function isSatisfiedBy(mixed $product): bool
    {
        return !empty(array_intersect($product->getCategoryIds(), $this->categoryIds));
    }
}

// Filtrowanie kolekcji - Specification jako predykat
class ProductFilter
{
    public function filter(array $products, SpecificationInterface $spec): array
    {
        return array_values(
            array_filter($products, fn($p) => $spec->isSatisfiedBy($p))
        );
    }
}

// Użycie - dynamiczne składanie filtrów z parametrów requestu
function buildProductSpec(array $filters): SpecificationInterface
{
    $spec = new InStockSpecification(); // zawsze filtruj dostępne

    if (isset($filters['min_price'], $filters['max_price'])) {
        $spec = $spec->and(new PriceRangeSpecification(
            (float) $filters['min_price'],
            (float) $filters['max_price']
        ));
    }

    if (!empty($filters['category_ids'])) {
        $spec = $spec->and(new CategorySpecification($filters['category_ids']));
    }

    return $spec;
}

$filter = new ProductFilter();
$spec   = buildProductSpec(['min_price' => 10, 'max_price' => 100, 'category_ids' => [5, 8]]);
$result = $filter->filter($products, $spec);

Testowanie Specification – trywialne

<?php

use PHPUnit\Framework\TestCase;

class MinimumOrderValueSpecificationTest extends TestCase
{
    public function testSatisfiedWhenOrderExceedsMinimum(): void
    {
        $spec = new MinimumOrderValueSpecification(100.0);

        $order = $this->createMock(Order::class);
        $order->method('getTotal')->willReturn(150.0);

        $this->assertTrue($spec->isSatisfiedBy(['order' => $order, 'customer' => null]));
    }

    public function testNotSatisfiedWhenOrderBelowMinimum(): void
    {
        $spec = new MinimumOrderValueSpecification(100.0);

        $order = $this->createMock(Order::class);
        $order->method('getTotal')->willReturn(50.0);

        $this->assertFalse($spec->isSatisfiedBy(['order' => $order, 'customer' => null]));
    }
}

// Testowanie kompozycji
class AndSpecificationTest extends TestCase
{
    public function testBothMustBeSatisfied(): void
    {
        $alwaysTrue  = new class extends AbstractSpecification {
            public function isSatisfiedBy(mixed $c): bool { return true; }
        };
        $alwaysFalse = new class extends AbstractSpecification {
            public function isSatisfiedBy(mixed $c): bool { return false; }
        };

        $and = $alwaysTrue->and($alwaysFalse);
        $this->assertFalse($and->isSatisfiedBy(null));

        $and2 = $alwaysTrue->and($alwaysTrue);
        $this->assertTrue($and2->isSatisfiedBy(null));
    }
}

Podsumowanie

Specification to wzorzec który rozwiązuje problem reguł biznesowych rozsianych po kodzie. Każda reguła to osobna klasa – prosta, testowalna, z jasną nazwą. Kompozycja przez AND/OR/NOT pozwala budować złożone warunki bez zagnieżdżonych if-else. W kontekście Magento 2 Specification świetnie pasuje do reguł kwalifikacji do rabatów, filtrowania produktów i walidacji zamówień – wszędzie tam gdzie ta sama logika pojawia się w kilku miejscach kodu.

About Henryk Tews

Co możesz przeczytać następne

Singleton i Builder w PHP – wzorce kreacyjne
Wzorzec Template Method – szkielet algorytmu, hooks, abstract vs hook, porównanie ze Strategy
Factory Method + Abstract Factory – implementacje od zera, tabela różnic, Simple Factory jako alternatywa
  • Publikacje
  • O autorze
  • Kontakt

© 2026 Created by

GÓRA
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Zawsze aktywne
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Zarządzaj opcjami
  • Zarządzaj serwisami
  • Zarządzaj {vendor_count} dostawcami
  • Przeczytaj więcej o tych celach
Zobacz preferencje
  • {title}
  • {title}
  • {title}