Strategy Pattern in Magento 2: Interchangeable Algorithms | Mironsoft
AI generated

Strategy Pattern: Interchangeable Algorithms in Magento

· Reading time: approx. 13 minutes · Category: Magento 2 · Design Patterns

STR
algo
Magento 2 · Deep Dive · Design Patterns

Strategy Pattern:
Interchangeable Algorithms

Price calculation, shipping costs, taxes, payment methods: Magento uses the Strategy Pattern everywhere. How it is implemented, how di.xml swaps algorithms, and how you integrate your own strategies.

⏱ 13 min. Deep Dive Design Patterns PHP 8.4

When Algorithms Need to Be Interchangeable

How does Magento calculate the price of a product? That depends: does the customer belong to a customer group with a discount? Is there an active price rule? Is the customer in a tax collective? Which currency is active? The answer is different every time, and that is exactly what the Strategy Pattern is ideal for.

The Strategy Pattern (GoF, 1994) defines a family of algorithms, encapsulates each one individually, and makes them interchangeable. The context (the calling class) only knows the interface, the configuration decides which concrete implementation is executed.

Magento uses this pattern extensively: shipping cost calculation (CarrierInterface), payment methods (MethodInterface), tax calculations, price calculators, and many other interchangeable algorithms are all Strategy implementations.

1. The GoF Strategy Pattern

The classic Strategy Pattern consists of three parts: the strategy interface, concrete implementations, and a context that uses a strategy:


<?php
// Classic Strategy Pattern:

// 1. Strategy interface
interface SortStrategyInterface
{
    public function sort(array $data): array;
}

// 2. Concrete strategies
class QuickSort implements SortStrategyInterface
{
    public function sort(array $data): array { /* QuickSort */ return $data; }
}

class MergeSort implements SortStrategyInterface
{
    public function sort(array $data): array { /* MergeSort */ return $data; }
}

class BubbleSort implements SortStrategyInterface
{
    public function sort(array $data): array { /* BubbleSort */ return $data; }
}

// 3. Context: knows only the interface
class DataSorter
{
    public function __construct(
        private readonly SortStrategyInterface $strategy // Interchangeable!
    ) {}

    public function sort(array $data): array
    {
        return $this->strategy->sort($data); // Which implementation? Doesn't matter!
    }
}

// Configuration decides which strategy:
$sorter = new DataSorter(new QuickSort());
$sorter = new DataSorter(new MergeSort()); // No code change in the context!

2. Strategy Pattern in the Magento Core: Overview


Strategy Pattern in the Magento Core:

Shipping costs:
  Interface: Magento\Shipping\Model\Carrier\AbstractCarrier
  Strategies: FlatRate, FreeShipping, TableRate, UPS, DHL, FedEx
  Context:   Magento\Shipping\Model\Shipping

Payment methods:
  Interface: Magento\Payment\Model\Method\AbstractMethod
             (+ Magento\Payment\Gateway\CommandInterface)
  Strategies: CashOnDelivery, BankTransfer, Stripe, PayPal, Klarna
  Context:   Magento\Payment\Helper\Data

Price calculators:
  Interface: Magento\Catalog\Pricing\Price\PriceInterface
  Strategies: RegularPrice, SpecialPrice, GroupPrice, TierPrice
  Context:   Magento\Framework\Pricing\Amount\AmountFactory

Tax calculation:
  Interface: Magento\Tax\Model\Calculation\AbstractAggregateCalculator
  Strategies: UnitBaseCalculation, RowBaseCalculation, TotalBaseCalculation
  Context:   Magento\Tax\Model\Calculation

URL generation:
  Interface: Magento\Catalog\Model\ResourceModel\Url
  Strategies: different URL builders depending on product type

Product types:
  Interface: Magento\Catalog\Model\Product\Type\AbstractType
  Strategies: Simple, Configurable, Bundle, Virtual, Downloadable

3. Shipping Costs: CarrierInterface as a Strategy

The clearest example: each shipping method is its own independent strategy. They share the same interface but have completely different calculation logic:


<?php
// Magento\Shipping\Model\CarrierInterface (simplified):
interface CarrierInterface
{
    /**
     * Collect and get rates.
     * Each carrier implements its own rate-calculation algorithm.
     */
    public function collectRates(RateRequest $request): ?Result;

    /**
     * Returns allowed shipping methods for this carrier.
     */
    public function getAllowedMethods(): array;
}

// Concrete strategies:
class Flatrate extends AbstractCarrier implements CarrierInterface
{
    public function collectRates(RateRequest $request): ?Result
    {
        // Always the same price from configuration
        $price = $this->getConfigData('price');
        $method = $this->_rateMethodFactory->create();
        $method->setPrice($price);
        // ...
        return $result;
    }
}

class Tablerate extends AbstractCarrier implements CarrierInterface
{
    public function collectRates(RateRequest $request): ?Result
    {
        // Price from a table based on weight/price/destination
        $rate = $this->_tablerateFactory->create()
            ->getRate($request);
        // ...
        return $result;
    }
}

// Context: the Shipping class knows only the interface
class Shipping
{
    public function collectCarrierRates(string $carrierCode, RateRequest $request): ?Result
    {
        $carrier = $this->_carrierFactory->create($carrierCode);
        if (!$carrier) {
            return null;
        }
        // Calls collectRates(), which implementation? Doesn't matter!
        return $carrier->collectRates($request);
    }
}

4. Price Calculation: PriceInterface Strategies


<?php
// Magento\Framework\Pricing\Price\PriceInterface:
interface PriceInterface
{
    /**
     * Get price value: each price type has its own calculation.
     */
    public function getValue(): float|false;

    /**
     * Get the price amount object with adjustments.
     */
    public function getAmount(): AmountInterface;
}

// Price strategies:
class RegularPrice implements PriceInterface
{
    public function getValue(): float|false
    {
        return $this->product->getPrice(); // Direct catalog price
    }
}

class SpecialPrice implements PriceInterface
{
    public function getValue(): float|false
    {
        $specialPrice = $this->product->getSpecialPrice();
        if ($specialPrice === null) {
            return false;
        }
        // Checks validity (from/to date)
        if ($this->isDateInRange($this->product->getSpecialFromDate(), $this->product->getSpecialToDate())) {
            return (float) $specialPrice;
        }
        return false;
    }
}

class TierPrice implements PriceInterface
{
    public function getValue(): float|false
    {
        // Lowest tier price for the current customer group
        $tierPrices = $this->product->getTierPrices();
        // ... calculation based on quantity and customer group
        return $lowestTierPrice;
    }
}

5. Implementing Your Own Strategy

A concrete example: your own discount strategy for different customer groups:


<?php
declare(strict_types=1);

namespace Mironsoft\Pricing\Model\Discount;

// 1. Define the strategy interface
interface DiscountStrategyInterface
{
    /**
     * Calculate discount amount for a given price and context.
     */
    public function calculate(float $price, DiscountContext $context): float;

    /**
     * Returns true if this strategy applies for the given context.
     */
    public function isApplicable(DiscountContext $context): bool;
}

// 2. Concrete context (value object)
class DiscountContext
{
    public function __construct(
        public readonly int $customerGroupId,
        public readonly float $cartTotal,
        public readonly \DateTimeImmutable $orderDate
    ) {}
}

// 3. Strategy A: customer group discount
class CustomerGroupDiscount implements DiscountStrategyInterface
{
    private const VIP_GROUP_ID = 4;
    private const VIP_DISCOUNT = 0.15; // 15%

    public function calculate(float $price, DiscountContext $context): float
    {
        return $price * self::VIP_DISCOUNT;
    }

    public function isApplicable(DiscountContext $context): bool
    {
        return $context->customerGroupId === self::VIP_GROUP_ID;
    }
}

// 4. Strategy B: cart value discount
class CartValueDiscount implements DiscountStrategyInterface
{
    public function calculate(float $price, DiscountContext $context): float
    {
        return match(true) {
            $context->cartTotal >= 500.0 => $price * 0.10,
            $context->cartTotal >= 200.0 => $price * 0.05,
            default => 0.0,
        };
    }

    public function isApplicable(DiscountContext $context): bool
    {
        return $context->cartTotal >= 200.0;
    }
}

// 5. Context class: knows only the interface
class DiscountCalculator
{
    public function __construct(
        private readonly DiscountStrategyInterface $strategy // Interchangeable!
    ) {}

    public function getDiscount(float $price, DiscountContext $context): float
    {
        if (!$this->strategy->isApplicable($context)) {
            return 0.0;
        }
        return $this->strategy->calculate($price, $context);
    }
}

6. di.xml: Configuring and Swapping Strategies

The strength of the Strategy Pattern in Magento: the strategy is configured via di.xml. No code change to the context is needed, only a configuration change:


<!-- app/code/Mironsoft/Pricing/etc/di.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">

    <!-- Default strategy: CustomerGroupDiscount -->
    <preference for="Mironsoft\Pricing\Model\Discount\DiscountStrategyInterface"
                type="Mironsoft\Pricing\Model\Discount\CustomerGroupDiscount"/>

    <!-- Alternative: CartValueDiscount instead of CustomerGroupDiscount -->
    <!-- Uncomment to swap: -->
    <!--
    <preference for="Mironsoft\Pricing\Model\Discount\DiscountStrategyInterface"
                type="Mironsoft\Pricing\Model\Discount\CartValueDiscount"/>
    -->

    <!-- Different strategy for a specific class (Virtual Type Pattern): -->
    <virtualType name="Mironsoft\Pricing\Model\VipDiscountCalculator"
                 type="Mironsoft\Pricing\Model\Discount\DiscountCalculator">
        <arguments>
            <argument name="strategy" xsi:type="object">
                Mironsoft\Pricing\Model\Discount\CustomerGroupDiscount
            </argument>
        </arguments>
    </virtualType>

    <virtualType name="Mironsoft\Pricing\Model\CartDiscountCalculator"
                 type="Mironsoft\Pricing\Model\Discount\DiscountCalculator">
        <arguments>
            <argument name="strategy" xsi:type="object">
                Mironsoft\Pricing\Model\Discount\CartValueDiscount
            </argument>
        </arguments>
    </virtualType>
</config>

Virtual Types: With Virtual Types, the same context (DiscountCalculator) can exist multiple times with different strategies, without having to write new PHP classes. VipDiscountCalculator and CartDiscountCalculator are the same class, just injected with a different strategy.

7. Strategy Chain: Combining Multiple Strategies


<?php
declare(strict_types=1);

namespace Mironsoft\Pricing\Model\Discount;

/**
 * Composite strategy: tries multiple strategies, applies the first that matches.
 * Implements the Chain of Responsibility variant of Strategy Pattern.
 */
class CompositeDiscountStrategy implements DiscountStrategyInterface
{
    /**
     * @param DiscountStrategyInterface[] $strategies Ordered list of strategies
     */
    public function __construct(
        private readonly array $strategies = []
    ) {}

    public function calculate(float $price, DiscountContext $context): float
    {
        foreach ($this->strategies as $strategy) {
            if ($strategy->isApplicable($context)) {
                return $strategy->calculate($price, $context);
            }
        }
        return 0.0;
    }

    public function isApplicable(DiscountContext $context): bool
    {
        foreach ($this->strategies as $strategy) {
            if ($strategy->isApplicable($context)) {
                return true;
            }
        }
        return false;
    }
}

<!-- Configure the composite strategy via di.xml: -->
<type name="Mironsoft\Pricing\Model\Discount\CompositeDiscountStrategy">
    <arguments>
        <argument name="strategies" xsi:type="array">
            <!-- sortOrder determines priority (VIP first, then cart value) -->
            <item name="vip" xsi:type="object" sortOrder="10">
                Mironsoft\Pricing\Model\Discount\CustomerGroupDiscount
            </item>
            <item name="cart" xsi:type="object" sortOrder="20">
                Mironsoft\Pricing\Model\Discount\CartValueDiscount
            </item>
        </argument>
    </arguments>
</type>

8. Testing the Strategy Pattern


<?php
declare(strict_types=1);

use Mironsoft\Pricing\Model\Discount\CustomerGroupDiscount;
use Mironsoft\Pricing\Model\Discount\DiscountContext;
use Mironsoft\Pricing\Model\Discount\DiscountCalculator;

class CustomerGroupDiscountTest extends \PHPUnit\Framework\TestCase
{
    private CustomerGroupDiscount $strategy;

    protected function setUp(): void
    {
        $this->strategy = new CustomerGroupDiscount();
    }

    public function testCalculateVipDiscount(): void
    {
        $context = new DiscountContext(
            customerGroupId: 4, // VIP group
            cartTotal: 100.0,
            orderDate: new \DateTimeImmutable()
        );

        $discount = $this->strategy->calculate(100.0, $context);
        $this->assertSame(15.0, $discount); // 15% of 100 = 15
    }

    public function testNotApplicableForNonVip(): void
    {
        $context = new DiscountContext(customerGroupId: 1, cartTotal: 0.0, orderDate: new \DateTimeImmutable());
        $this->assertFalse($this->strategy->isApplicable($context));
    }
}

class DiscountCalculatorTest extends \PHPUnit\Framework\TestCase
{
    public function testUsesStrategy(): void
    {
        $context  = new DiscountContext(customerGroupId: 4, cartTotal: 100.0, orderDate: new \DateTimeImmutable());
        $strategy = $this->createMock(\Mironsoft\Pricing\Model\Discount\DiscountStrategyInterface::class);
        $strategy->method('isApplicable')->with($context)->willReturn(true);
        $strategy->method('calculate')->with(100.0, $context)->willReturn(20.0);

        $calculator = new DiscountCalculator($strategy);
        $this->assertSame(20.0, $calculator->getDiscount(100.0, $context));
    }
}

9. Strategy vs. Plugin: Which Pattern When?

Criterion Strategy Pattern Plugin (Interceptor)
PurposeReplace the entire algorithmExtend an existing method before/after/around
Configurationdi.xml preference or argumentdi.xml plugin element
Multiple at onceOnly one (or a composite)Several with sortOrder
Original codeCompletely replacedPreserved (before/after) or optional (around)
Use caseCustom business logic, extensibility pointsModify the behavior of core classes

Mironsoft

Magento 2 Architecture & Development

Want to develop your own strategies for your Magento store?

We implement interchangeable algorithms for price calculation, shipping costs, tax rules and more, using the Strategy Pattern, full test coverage and di.xml configuration.

Pricing strategies
Custom price calculators for customer groups, cart value, returning customers or B2B customers.
Shipping strategies
Custom carriers with your own pricing logic: distance-based, weight-dependent, customer-specific.
Extensibility points
We define strategy interfaces in your own modules so third-party modules can provide interchangeable implementations.

10. Summary

The Strategy Pattern is the most commonly used design pattern in Magento 2. It enables interchangeable algorithms for price calculation, shipping costs, payment methods and more. The interface defines the contract, di.xml decides which implementation is active, and the calling context knows no concrete classes.

Strategy Pattern in Magento, Overview

Interface

Defines the algorithm contract. The context knows only the interface. All strategies implement the same interface.

di.xml configuration

A preference or argument selects the concrete implementation. Swapping means a configuration change, no code change in the context.

Virtual Types

The same context with different strategies as different virtual classes, no new PHP classes needed.

Composite strategy

Combine multiple strategies in a composite strategy. Order is configurable via sortOrder in di.xml.

11. FAQ: Strategy Pattern in Magento 2

1 Main advantage of the Strategy Pattern in Magento?
The context is decoupled from the concrete implementation. New algorithms can be added without code changes (Open/Closed). Configuration via di.xml. Full testability through interface mocking.
2 How do I swap a standard strategy?
Via di.xml preference: <preference for="..." type="MyImplementation"/>. Active after setup:di:compile. No code change needed in the context.
3 Multiple strategies at the same time?
Yes, with a Composite Strategy: a list of strategies, the first applicable one wins. Configure the list as an array argument in di.xml with sortOrder.
4 Strategy vs. Plugin: which one when?
Strategy: replace the entire algorithm, define your own extensibility points. Plugin: extend the behavior of a core method without replacing the original.
5 Define your own extensibility point?
1. Define an interface. 2. Create a default implementation. 3. The context injects the interface. 4. di.xml: preference pointing to the default. Third-party modules can then register their own implementations.
6 What are Virtual Types in Strategy?
Configuration variants of the same class without new PHP code. DiscountCalculator as VipDiscountCalculator plus CartDiscountCalculator, the same class, different strategy injected. Fully configurable in di.xml.
7 Test strategy implementations?
Very simple: create the context (value object), instantiate the strategy, call calculate(), check the result. No bootstrap, no database. Test the context with createMock(StrategyInterface::class).
8 Implement your own shipping carrier?
Extend AbstractCarrier and implement CarrierInterface. Implement collectRates(RateRequest) with your own logic. Configure in config.xml and system.xml. Magento detects the carrier automatically.
9 Strategy selection from the database?
Yes, with a strategy resolver: reads from the database which strategy applies, returns the corresponding implementation. Strategies remain configured via di.xml, the resolver only decides which one is active.
10 Strategy vs. Template Method Pattern?
Strategy: the whole algorithm is interchangeable, independent classes, switching via injection. Template Method: the base algorithm is fixed in a base class, hooks for subclasses, switching via inheritance. Magento uses both: AbstractCarrier (Template Method) plus CarrierInterface (Strategy).