# Strategy & Factory Patterns: Payment & Courier Code

Almost every e-commerce or SaaS backend starts with a single payment gateway and a single shipping carrier. A developer writes a quick `if ($gateway === 'stripe')` check inside `CheckoutService`, ships the feature, and moves on. Six months later, the business expands: now you need PayPal, JazzCash, Safepay, and Cash on Delivery (COD) for payments, plus DHL, TCS, and Leopards for courier booking. Suddenly, `CheckoutService` is a 900-line tangle of nested `if/else` and `switch` blocks where a bug in one courier's API payload breaks checkout for every customer. By pairing the **Strategy Pattern** with the **Factory Pattern**, you can isolate every third-party integration behind a strict contract so adding a new provider requires zero edits to your core checkout flow.

![Strategy & Factory Patterns: Payment & Courier Code](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/k9ff0tqnjw3fkszgrwnr.jpg)

## Why `if/else` Provider Switching Breaks in Production

When our team at [Loop Origin](https://looporigin.com/services/ecommerce-website-development?utm_source=hashnode&utm_medium=article&utm_campaign=strategy-factory-patterns-payment-courier-integrations) builds or refactors high-volume online stores—especially multi-gateway regional storefronts like those discussed in my guide on [launching an online store in Pakistan (platforms, payments, COD, and hosting)](https://dev.to/muhammadalimughal/launching-an-online-store-in-pakistan-a-practical-guide-to-platforms-payments-cod-and-hosting-155e)—the biggest source of checkout regressions is provider logic leaking directly into controllers and order services.

Here is what the classic anti-pattern looks like before refactoring:

```php
// ANTI-PATTERN: Violates Open/Closed Principle & Single Responsibility
class OrderFulfillmentService
{
    public function chargeAndDispatch(Order $order, string $gateway, string $courier): void
    {
        if ($gateway === 'stripe') {
            // 40 lines of Stripe PaymentIntent SDK calls & currency conversion
        } elseif ($gateway === 'safepay') {
            // 35 lines of HMAC signature generation & checkout session creation
        } elseif ($gateway === 'cod') {
            // 20 lines of COD verification & phone OTP check
        } else {
            throw new InvalidArgumentException("Unsupported gateway");
        }

        if ($courier === 'dhl') {
            // 50 lines of DHL XML/JSON airwaybill generation
        } elseif ($courier === 'tcs') {
            // 45 lines of TCS domestic consignment booking
        }
    }
}
```

Why does this become dangerous as transaction volume grows?
1. **Every New Integration Risks Breaking Existing Gateways:** Editing a 900-line file to add a fourth payment method means touching the exact same method that processes your existing Stripe and COD orders.
2. **Impossible to Unit Test in Isolation:** To test a single courier's weight calculation, your test harness has to instantiate a monolithic service coupled to five different SDKs.
3. **Inconsistent Error and Return Types:** Without a strict interface, one developer returns an associative array `['status' => 'ok', 'id' => '...']`, another returns a raw Guzzle response, and a third throws an untyped exception.

## How Strategy and Factory Work Together

People often ask whether they should use the **Strategy Pattern** or the **Factory Pattern** for third-party integrations. In production backend code, **you use both together**:

- **The Strategy Pattern** defines a uniform **Contract (Interface)** and puts each provider's implementation inside its own self-contained class (`StripePaymentStrategy`, `SafepayPaymentStrategy`, `CodPaymentStrategy`).
- **The Factory Pattern** decides **which concrete Strategy class to instantiate** based on a runtime key (such as `$order->payment_method`, user country, or tenant configuration) and hands it back typed as the interface.

## Comparison Table: Hardcoded Conditionals vs. Strategy + Factory Architecture

| Architectural Dimension | Hardcoded `if/else` / `switch` in Service | Strategy Pattern + Factory Resolver |
| :--- | :--- | :--- |
| **Adding a New Provider** | Edit core `CheckoutService` and `WebhookController` in 4+ places | Create 1 new class implementing `PaymentGatewayInterface` and register key in Factory map |
| **Return Payload Consistency** | Arbitrary arrays or raw SDK objects per branch | Enforced immutable Value Object / DTO (`ChargeResult`, `ShipmentLabel`) |
| **Unit Testing** | Requires mocking 5 SDKs inside one giant service test | Each provider strategy is tested in complete isolation; core checkout tests use a `FakePaymentGateway` |
| **Runtime Fallback / Failover** | Deeply nested `try/catch` blocks inside the controller | Factory or Decorator can transparently swap to a backup courier strategy if primary API times out |
| **Blast Radius of API Changes** | High—touching shared checkout class risks all payment flows | Zero—changes to TCS API stay strictly inside `TcsCourierStrategy` |

## Step-by-Step Implementation: Payment & Courier Integration in PHP / Laravel

Let's build a clean, production-grade implementation using strict DTOs, Strategy interfaces, Concrete Strategies, and a container-backed Factory.

### 1. Define Immutable Data Transfer Objects (DTOs) and the Strategy Interface

Never return loose associative arrays from a payment or courier driver. Define a readonly DTO so static analysis (`PHPStan` / `Psalm`) and IDE autocomplete guarantee every field exists:

```php
namespace App\Domain\Payments\Data;

final readonly class ChargeResult
{
    public function __construct(
        public bool $successful,
        public string $providerReference,
        public int $amountMinorUnits,
        public string $currency,
        public ?string $redirectUrl = null,
    ) {}
}
```

Now define the Strategy contract that every payment provider must obey:

```php
namespace App\Domain\Payments\Contracts;

use App\Domain\Payments\Data\ChargeResult;
use App\Models\Order;

interface PaymentGatewayStrategy
{
    public function charge(Order $order, string $idempotencyKey): ChargeResult;

    public function refund(string $providerReference, int $amountMinorUnits): bool;

    public function verifyWebhookSignature(string $rawPayload, array $headers): bool;
}
```

### 2. Implement Concrete Strategies for Each Provider

Each provider lives in its own focused class with its own constructor dependencies:

```php
namespace App\Domain\Payments\Strategies;

use App\Domain\Payments\Contracts\PaymentGatewayStrategy;
use App\Domain\Payments\Data\ChargeResult;
use App\Models\Order;
use Stripe\StripeClient;

final class StripeGatewayStrategy implements PaymentGatewayStrategy
{
    public function __construct(private readonly StripeClient $stripe) {}

    public function charge(Order $order, string $idempotencyKey): ChargeResult
    {
        $intent = $this->stripe->paymentIntents->create([
            'amount'   => $order->total_cents,
            'currency' => strtolower($order->currency),
            'metadata' => ['order_id' => $order->id],
        ], ['idempotency_key' => $idempotencyKey]);

        return new ChargeResult(
            successful: in_array($intent->status, ['succeeded', 'requires_capture'], true),
            providerReference: $intent->id,
            amountMinorUnits: $order->total_cents,
            currency: $order->currency,
            redirectUrl: $intent->next_action?->redirect_to_url?->url,
        );
    }

    public function refund(string $providerReference, int $amountMinorUnits): bool
    {
        $refund = $this->stripe->refunds->create([
            'payment_intent' => $providerReference,
            'amount'         => $amountMinorUnits,
        ]);
        return $refund->status === 'succeeded';
    }

    public function verifyWebhookSignature(string $rawPayload, array $headers): bool
    {
        // Verify Stripe-Signature header using HMAC
        return true;
    }
}
```

### 3. Build the Factory Using the Dependency Injection Container

Instead of instantiating classes with `new` inside a `switch`, map provider keys to class names and let Laravel's IoC container (or Symfony/NestJS DI) inject each strategy's dependencies:

```php
namespace App\Domain\Payments;

use App\Domain\Payments\Contracts\PaymentGatewayStrategy;
use App\Domain\Payments\Strategies\CodGatewayStrategy;
use App\Domain\Payments\Strategies\SafepayGatewayStrategy;
use App\Domain\Payments\Strategies\StripeGatewayStrategy;
use Illuminate\Contracts\Container\Container;
use InvalidArgumentException;

final class PaymentGatewayFactory
{
    /** @var array<string, class-string<PaymentGatewayStrategy>> */
    private const STRATEGIES = [
        'stripe'  => StripeGatewayStrategy::class,
        'safepay' => SafepayGatewayStrategy::class,
        'cod'     => CodGatewayStrategy::class,
    ];

    public function __construct(private readonly Container $container) {}

    public function make(string $driver): PaymentGatewayStrategy
    {
        $strategyClass = self::STRATEGIES[strtolower($driver)] ?? null;

        if ($strategyClass === null) {
            throw new InvalidArgumentException("Unsupported payment driver: {$driver}");
        }

        return $this->container->make($strategyClass);
    }
}
```

### 4. Clean Checkout Orchestration (Zero Conditionals)

Look at `CheckoutService` now—it has zero knowledge of Stripe, Safepay, DHL, or TCS internals. And when you combine this clean service boundary with atomic inventory holds (as detailed in my guide on [how to prevent double booking with locks, holds, and expiry](https://dev.to/muhammadalimughal/how-to-prevent-double-booking-locks-holds-and-expiry-5e54)), your checkout flow stays rock-solid under high concurrency:

```php
final class CheckoutService
{
    public function __construct(
        private readonly PaymentGatewayFactory $payments,
        private readonly CourierStrategyFactory $couriers,
    ) {}

    public function processOrder(Order $order): ChargeResult
    {
        $gateway = $this->payments->make($order->payment_driver);
        $result  = $gateway->charge($order, idempotencyKey: "order_{$order->id}_charge");

        if ($result->successful) {
            $courier = $this->couriers->make($order->courier_driver);
            $shipment = $courier->bookShipment($order);
            $order->markFulfilled($result->providerReference, $shipment->trackingNumber);
        }

        return $result;
    }
}
```

## 6-Step Architecture Checklist for Payment & Courier Integrations

Before you integrate your next payment gateway or logistics API, run through this checklist:

- [ ] **Ban Raw SDK Objects Above the Strategy Boundary:** Never return a `Stripe\PaymentIntent` or a carrier-specific XML object to your controller; map every response into an application-owned DTO (`ChargeResult`, `ShipmentResult`).
- [ ] **Pass an Explicit `idempotencyKey` in the Strategy Contract:** Make idempotency a required parameter on `charge()` and `bookShipment()` so network retries never double-charge a card or generate duplicate airwaybills.
- [ ] **Use Minor Currency Units (`int` Cents/Paisa) Everywhere:** Never pass floating-point `float` amounts (`19.99`) across strategy interfaces; use integer minor units (`1999`) paired with an ISO 4217 currency code (`USD`, `PKR`, `GBP`).
- [ ] **Route Webhooks Through the Same Factory:** Use `PaymentGatewayFactory::make($provider)` in a unified `/webhooks/payments/{provider}` endpoint so signature verification and status normalization follow the same pattern.
- [ ] **Write a Contract Test Suite Shared by All Strategies:** Create an abstract test trait that verifies every registered strategy handles timeouts, invalid credentials, and idempotent retries consistently.
- [ ] **Keep Core Domain Logic Provider-Agnostic:** If you are scaling a multi-country or multi-courier storefront and want an architecture built to last, see how our engineering team designs e-commerce backends at [Loop Origin](https://looporigin.com/?utm_source=hashnode&utm_medium=article&utm_campaign=strategy-factory-patterns-payment-courier-integrations).

## Frequently Asked Questions (FAQ)

### What is the difference between the Strategy Pattern and the Factory Pattern?
The **Strategy Pattern** is a *behavioral* pattern that defines a family of interchangeable algorithms (such as charging via Stripe vs. charging via PayPal) behind a common interface. The **Factory Pattern** is a *creational* pattern whose job is to construct and return the right Strategy instance based on a runtime value (such as `$order->payment_method`).

### How do I handle a provider that requires extra fields other providers don't need?
Keep the core `PaymentGatewayStrategy::charge(Order $order, string $idempotencyKey)` signature uniform across all providers, and encapsulate provider-specific metadata inside either a typed value object on the `Order` context or inside the concrete strategy itself (for example, `CodGatewayStrategy` can inspect `$order->shippingAddress->phone` without polluting the method arguments for Stripe).

### Should I use a Factory class or Laravel's built-in `Manager` pattern?
Laravel's `Illuminate\Support\Manager` class (used internally by `Cache`, `Storage`, and `Mail`) is a battle-tested implementation of the Factory + Strategy pattern. Writing a dedicated `PaymentGatewayFactory` backed by a `const STRATEGIES` map is slightly lighter and framework-agnostic, while extending `Manager` is great if you want `Payment::driver('stripe')` facade syntax.

### How do I handle automatic failover if a courier API is down?
Because every courier implements `CourierStrategy`, you can wrap two strategies inside a `FailoverCourierStrategy` (using the Decorator/Chain pattern) that attempts `primary->bookShipment($order)` and automatically falls back to `secondary->bookShipment($order)` on a `5xx` or connection timeout.

### How do I unit test `CheckoutService` without hitting real Stripe or DHL APIs?
Bind an in-memory `FakePaymentStrategy` and `FakeCourierStrategy` in your test container. Your `CheckoutService` tests will run in milliseconds without network calls, while each concrete strategy has its own integration test suite against mocked HTTP fixtures.

