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 |
|---|---|
| ProductRepository | Fetches product data — nothing else |
| StockRepository | Owns stock levels and availability checks |
| DiscountService | Applies pricing discounts |
| StripePaymentService | Processes payment |
| OrderProcessingService | Orchestrates 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',
];
}
}