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.
