S — SOLID Design Principle

Single Responsibility Principle

A class should have only one reason to change.

Problem

OrderProcessingService looks up the product, checks stock, calculates the discount, processes payment, AND updates stock — all in one method. Changing any one of those five things means editing (and re-testing) the whole class.

Solution

Split each responsibility into its own class: ProductRepository, StockRepository, DiscountService, StripePaymentService. OrderProcessingService becomes a thin orchestrator that delegates to each collaborator.

Analogy

A restaurant where one person takes orders, cooks, serves, AND does the accounts — versus a kitchen where each role is separate. One sick day shouldn't shut down the whole restaurant.

Participants (After)

Class Role
ProductRepositoryFetches product data — nothing else
StockRepositoryOwns stock levels and availability checks
DiscountServiceApplies pricing discounts
StripePaymentServiceProcesses payment
OrderProcessingServiceOrchestrates the above — one reason to change: the workflow order

Run the Demo

Activity Log

Press a button above to see the pattern in action.

Source Code

Before: OrderProcessingService.php
<?php

declare(strict_types=1);

namespace App\SolidPrinciples\Srp\Before;

/**
 * SRP VIOLATION — this single class is responsible for:
 *   1. Looking up the product
 *   2. Checking stock levels
 *   3. Calculating the discount
 *   4. Processing the payment
 *   5. Updating stock
 *
 * Five reasons to change, all living in one method. Change the discount
 * rules, the payment provider, or the stock logic — you're editing the
 * same class every time, risking breaking the others.
 */
class OrderProcessingService
{
    /** @var array<int, array{id:int,name:string,price:float,stock:int}> */
    private array $products = [
        1 => ['id' => 1, 'name' => 'Widget', 'price' => 49.99, 'stock' => 5],
    ];

    public function execute(int $productId): array
    {
        // Responsibility 1: find the product
        $product = $this->products[$productId] ?? null;
        if (!$product) {
            throw new \RuntimeException('Product not found');
        }

        // Responsibility 2: check stock
        if ($product['stock'] < 1) {
            throw new \RuntimeException('We are out of stock');
        }

        // Responsibility 3: calculate discount (hard-coded 20%)
        $discount = 0.20 * $product['price'];
        $total = number_format($product['price'] - $discount, 2);

        // Responsibility 4: process payment (hard-coded Stripe message)
        $paymentMessage = 'Processing payment of £' . $total . ' through Stripe';

        // Responsibility 5: update stock
        $this->products[$productId]['stock']--;

        return [
            'payment_message'  => $paymentMessage,
            'discounted_price' => $total,
            'original_price'   => $product['price'],
            'remaining_stock'  => $this->products[$productId]['stock'],
            'message'          => 'Thank you, your order is being processed',
        ];
    }
}
After: ProductRepository.php
<?php

declare(strict_types=1);

namespace App\SolidPrinciples\Srp\After;

/**
 * SRP FIX — sole responsibility: fetch product data.
 */
class ProductRepository
{
    /** @var array<int, array{id:int,name:string,price:float}> */
    private array $products = [
        1 => ['id' => 1, 'name' => 'Widget', 'price' => 49.99],
    ];

    public function getById(int $productId): ?array
    {
        return $this->products[$productId] ?? null;
    }
}
After: StockRepository.php
<?php

declare(strict_types=1);

namespace App\SolidPrinciples\Srp\After;

/**
 * SRP FIX — sole responsibility: stock levels for a product.
 */
class StockRepository
{
    private const MINIMUM_STOCK_LEVEL = 1;

    /** @var array<int, int> */
    private array $stock = [1 => 5];

    public function forProduct(int $productId): int
    {
        return $this->stock[$productId] ?? 0;
    }

    public function checkAvailability(int $productId): void
    {
        if ($this->forProduct($productId) < self::MINIMUM_STOCK_LEVEL) {
            throw new \RuntimeException('We are out of stock');
        }
    }

    public function record(int $productId): int
    {
        $this->stock[$productId]--;
        return $this->stock[$productId];
    }
}
After: DiscountService.php
<?php

declare(strict_types=1);

namespace App\SolidPrinciples\Srp\After;

/**
 * SRP FIX — sole responsibility: apply a discount to a price.
 */
class DiscountService
{
    public function apply(float $price, float $rate = 0.20): string
    {
        $discount = $rate * $price;
        return number_format($price - $discount, 2);
    }
}
After: StripePaymentService.php
<?php

declare(strict_types=1);

namespace App\SolidPrinciples\Srp\After;

/**
 * SRP FIX — sole responsibility: process a payment message.
 */
class StripePaymentService
{
    public function process(string $total): string
    {
        return 'Processing payment of £' . $total . ' through Stripe';
    }
}
After: OrderProcessingService.php
<?php

declare(strict_types=1);

namespace App\SolidPrinciples\Srp\After;

/**
 * SRP FIX — this class has exactly one reason to change: the order of
 * steps in the order workflow. Each collaborator owns its own concern.
 */
class OrderProcessingService
{
    public function __construct(
        private ProductRepository $productRepository,
        private StockRepository $stockRepository,
        private DiscountService $discountService,
        private StripePaymentService $paymentService,
    ) {}

    public function execute(int $productId): array
    {
        $product = $this->productRepository->getById($productId);
        if (!$product) {
            throw new \RuntimeException('Product not found');
        }

        $this->stockRepository->checkAvailability($productId);

        $total = $this->discountService->apply($product['price']);

        $paymentMessage = $this->paymentService->process($total);

        $remainingStock = $this->stockRepository->record($productId);

        return [
            'payment_message'  => $paymentMessage,
            'discounted_price' => $total,
            'original_price'   => $product['price'],
            'remaining_stock'  => $remainingStock,
            'message'          => 'Thank you, your order is being processed',
        ];
    }
}