Dependency Inversion, Final Classes, and Pragmatic Testing in PHP 8.4
The evolution of PHP 8.4 brings powerful features that fundamentally change how we approach dependency inversion, class design, and testing strategies. While the SOLID principles remain timeless, the implementation details have evolved significantly with modern PHP capabilities, and the testing community has developed more nuanced approaches that go beyond the traditional "mock everything" mentality.
This comprehensive guide explores the intersection of three critical concepts: dependency inversion principle (DIP) with PHP 8.4's final classes, composition over inheritance patterns, and the pragmatic testing philosophy that combines the best of Detroit School (classical) and London School (mockist) approaches. We'll examine when to use real objects versus mocks, how to leverage union types for flexible testing, and why the "mockist vs classical TDD" debate has evolved into a more sophisticated understanding of testing strategies.
Understanding Dependency Inversion in Modern PHP
The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules—both should depend on abstractions. In PHP 8.4, this principle takes on new dimensions with enhanced language features like property hooks, asymmetric visibility, and improved type system capabilities.
The Problem: Violating Dependency Inversion
Let's first examine what not to do. The following pseudocode demonstrates a classic violation of DIP:
# Dependency Inversion Principle - Core Concepts
# 1. VIOLATION: High-level module depends on low-level module
CLASS OrderProcessor
PROPERTY sqlDatabase AS MySqlDatabase # Direct dependency!
METHOD processOrder(order)
sql = "INSERT INTO orders..."
this.sqlDatabase.execute(sql) # Tight coupling
END METHOD
END CLASS
# 2. SOLUTION: Both depend on abstraction
INTERFACE IOrderStorage
METHOD saveOrder(order) -> boolean
END INTERFACE
CLASS OrderProcessor # High-level module
PROPERTY storage AS IOrderStorage # Depends on abstraction
CONSTRUCTOR(storage AS IOrderStorage)
this.storage = storage
END CONSTRUCTOR
METHOD processOrder(order)
RETURN this.storage.saveOrder(order) # Loose coupling
END METHOD
END CLASS
CLASS MySqlOrderStorage IMPLEMENTS IOrderStorage # Low-level module
METHOD saveOrder(order) -> boolean
# MySQL implementation details
RETURN true
END METHOD
END CLASS
# 3. FINAL CLASSES WITH COMPOSITION
FINAL CLASS PaymentProcessor
PROPERTY paymentGateway AS IPaymentGateway
PROPERTY logger AS ILogger
CONSTRUCTOR(gateway AS IPaymentGateway, logger AS ILogger)
this.paymentGateway = gateway
this.logger = logger
END CONSTRUCTOR
METHOD processPayment(amount, method)
this.logger.info("Processing payment: " + amount)
result = this.paymentGateway.charge(amount, method)
IF result.success THEN
this.logger.info("Payment successful")
ELSE
this.logger.error("Payment failed: " + result.error)
END IF
RETURN result
END METHOD
END CLASS
# 4. TESTING STRATEGY: Detroit vs London Schools
# Detroit School (Classical) - Use real objects when possible
TEST_CLASS OrderProcessorTest
METHOD testProcessOrderWithInMemoryStorage()
# Use real, lightweight implementation
storage = NEW InMemoryOrderStorage()
processor = NEW OrderProcessor(storage)
order = NEW Order("123", 100.00)
result = processor.processOrder(order)
ASSERT result == true
ASSERT storage.getOrderCount() == 1
END METHOD
END TEST_CLASS
# London School (Mockist) - Mock dependencies for interaction testing
TEST_CLASS PaymentProcessorTest
METHOD testProcessPaymentLogsCorrectly()
# Mock when testing interactions are important
mockGateway = MOCK IPaymentGateway
mockLogger = MOCK ILogger
processor = NEW PaymentProcessor(mockGateway, mockLogger)
EXPECT mockGateway.charge(100, "credit") RETURNS successResult
EXPECT mockLogger.info("Processing payment: 100")
EXPECT mockLogger.info("Payment successful")
processor.processPayment(100, "credit")
VERIFY_ALL_EXPECTATIONS()
END METHOD
END TEST_CLASS
# 5. PRAGMATIC APPROACH: When to Mock vs Use Real Objects
# Use REAL objects when:
# - Easy to instantiate and configure
# - No external dependencies (database, network)
# - Fast execution
# - Deterministic behavior
# Use MOCKS when:
# - External resources (database, HTTP APIs)
# - Slow operations
# - Non-deterministic behavior (random, time-based)
# - Testing error conditions
# - Interaction verification is important
# 6. UNION TYPES FOR FLEXIBLE TESTING (Modern approach)
INTERFACE ITestableService
METHOD performOperation(data) -> Result
END INTERFACE
# Allow both real service AND mock in tests
TYPE TestService = RealService | MockService
WHERE MockService IMPLEMENTS ITestableService
AND RealService IMPLEMENTS ITestableService
TEST_CLASS FlexibleServiceTest
METHOD testWithRealService()
service AS TestService = NEW RealService()
result = service.performOperation(testData)
ASSERT result.isValid()
END METHOD
METHOD testWithMockService()
service AS TestService = MOCK MockService
EXPECT service.performOperation(testData) RETURNS expectedResult
# Test continues...
END METHOD
END TEST_CLASS
This inheritance-heavy approach creates several problems:
- Tight coupling: High-level OrderProcessor depends directly on low-level MySqlDatabase
- Hard to test: Cannot isolate business logic from database concerns
- Brittle inheritance: Changes to base class affect all subclasses
- Limited extensibility: Adding new database types requires modifying existing code
Here's how this anti-pattern manifests in PHP code:
<?php
declare(strict_types=1);
/**
* WRONG: Inheritance-heavy design with tight coupling
* Problems:
* - Violates Dependency Inversion Principle
* - Hard to test in isolation
* - Brittle inheritance hierarchy
* - Cannot be final (extensible by design)
*/
// Base class that does too much
abstract class BaseOrderProcessor
{
protected MySqlDatabase $database;
protected EmailService $emailService;
protected PaymentGateway $paymentGateway;
public function __construct()
{
// Direct dependencies - violation of DIP!
$this->database = new MySqlDatabase('localhost', 'orders_db');
$this->emailService = new SmtpEmailService('smtp.example.com');
$this->paymentGateway = new StripePaymentGateway('sk_test_123');
}
// Template method forcing inheritance
abstract protected function validateOrder(array $orderData): bool;
abstract protected function calculateTax(float $amount): float;
public function processOrder(array $orderData): bool
{
// Rigid workflow - hard to change
if (!$this->validateOrder($orderData)) {
return false;
}
$amount = $orderData['amount'];
$tax = $this->calculateTax($amount);
$total = $amount + $tax;
// Direct database access - tight coupling
$orderId = $this->database->insertOrder([
'customer_id' => $orderData['customer_id'],
'amount' => $total,
'status' => 'pending'
]);
// Payment processing mixed with order logic
$paymentResult = $this->paymentGateway->charge(
$total,
$orderData['payment_method']
);
if ($paymentResult->isSuccess()) {
$this->database->updateOrderStatus($orderId, 'paid');
$this->emailService->sendConfirmation(
$orderData['customer_email'],
$orderId
);
return true;
}
$this->database->updateOrderStatus($orderId, 'failed');
return false;
}
}
// Concrete implementations forced to extend
class StandardOrderProcessor extends BaseOrderProcessor
{
protected function validateOrder(array $orderData): bool
{
return isset($orderData['customer_id'], $orderData['amount']);
}
protected function calculateTax(float $amount): float
{
return $amount * 0.08; // 8% tax
}
}
class PremiumOrderProcessor extends BaseOrderProcessor
{
protected function validateOrder(array $orderData): bool
{
return isset($orderData['customer_id'], $orderData['amount'])
&& $orderData['amount'] >= 100.00;
}
protected function calculateTax(float $amount): float
{
return $amount * 0.05; // 5% tax for premium customers
}
}
/**
* Supporting classes with their own problems
*/
class MySqlDatabase
{
private string $host;
private string $database;
private PDO $connection;
public function __construct(string $host, string $database)
{
$this->host = $host;
$this->database = $database;
// Direct connection in constructor - hard to test
$this->connection = new PDO(
"mysql:host={$host};dbname={$database}",
'username',
'password'
);
}
public function insertOrder(array $data): int
{
// SQL directly embedded - not reusable
$sql = "INSERT INTO orders (customer_id, amount, status, created_at)
VALUES (?, ?, ?, NOW())";
$stmt = $this->connection->prepare($sql);
$stmt->execute([
$data['customer_id'],
$data['amount'],
$data['status']
]);
return (int) $this->connection->lastInsertId();
}
public function updateOrderStatus(int $orderId, string $status): bool
{
$sql = "UPDATE orders SET status = ? WHERE id = ?";
$stmt = $this->connection->prepare($sql);
return $stmt->execute([$status, $orderId]);
}
}
class SmtpEmailService
{
private string $smtpHost;
public function __construct(string $smtpHost)
{
$this->smtpHost = $smtpHost;
}
public function sendConfirmation(string $email, int $orderId): bool
{
// Direct SMTP call - external dependency
return mail(
$email,
'Order Confirmation',
"Your order #{$orderId} has been confirmed."
);
}
}
class StripePaymentGateway
{
private string $apiKey;
public function __construct(string $apiKey)
{
$this->apiKey = $apiKey;
}
public function charge(float $amount, string $paymentMethod): PaymentResult
{
// Simulated Stripe API call - external dependency
// In reality, this would make HTTP requests
$success = random_int(1, 10) > 2; // 80% success rate
return new PaymentResult(
$success,
$success ? null : 'Payment declined'
);
}
}
class PaymentResult
{
public function __construct(
private bool $success,
private ?string $error = null
) {}
public function isSuccess(): bool
{
return $this->success;
}
public function getError(): ?string
{
return $this->error;
}
}
/**
* TESTING NIGHTMARE - Why this approach is problematic
*/
// Attempting to test this is painful:
// - Cannot mock dependencies (they're created in constructor)
// - Tests hit real database and email service
// - Inheritance makes it hard to test individual pieces
// - Random payment results make tests flaky
class StandardOrderProcessorTest extends PHPUnit\Framework\TestCase
{
public function testProcessOrder(): void
{
// This test will:
// 1. Try to connect to actual MySQL database
// 2. Send real emails
// 3. Make actual payment gateway calls
// 4. Have random failures due to payment randomness
$processor = new StandardOrderProcessor();
$orderData = [
'customer_id' => 123,
'amount' => 100.00,
'payment_method' => 'credit_card',
'customer_email' => 'test@example.com'
];
// This will likely fail due to external dependencies
$result = $processor->processOrder($orderData);
// What are we actually testing here?
$this->assertTrue($result);
}
}
The Solution: Final Classes with Dependency Inversion
The modern PHP 8.4 approach leverages final classes combined with dependency injection to achieve proper inversion of control. Final classes prevent inheritance, forcing developers to use composition—which aligns perfectly with dependency inversion principles.
<?php
declare(strict_types=1);
/**
* RIGHT: Final classes with dependency inversion and composition
* Benefits:
* - Follows Dependency Inversion Principle
* - Easy to test with real objects or mocks as needed
* - Final classes prevent fragile inheritance
* - Composition over inheritance
* - PHP 8.4 features: Property hooks, lazy objects, asymmetric visibility
*/
/**
* Interfaces define contracts (abstractions)
*/
interface OrderStorageInterface
{
public function saveOrder(Order $order): OrderId;
public function updateOrderStatus(OrderId $orderId, OrderStatus $status): bool;
public function findOrder(OrderId $orderId): ?Order;
}
interface PaymentGatewayInterface
{
public function processPayment(Payment $payment): PaymentResult;
}
interface NotificationServiceInterface
{
public function sendOrderConfirmation(string $email, OrderId $orderId): bool;
}
interface TaxCalculatorInterface
{
public function calculateTax(Money $amount, CustomerType $customerType): Money;
}
interface OrderValidatorInterface
{
public function validate(OrderData $orderData): ValidationResult;
}
/**
* Value Objects - PHP 8.4 with property hooks and asymmetric visibility
*/
readonly class OrderId
{
public function __construct(private int $id)
{
if ($id <= 0) {
throw new InvalidArgumentException('Order ID must be positive');
}
}
public function value(): int
{
return $this->id;
}
public function __toString(): string
{
return (string) $this->id;
}
}
readonly class Money
{
public function __construct(private float $amount)
{
if ($amount < 0) {
throw new InvalidArgumentException('Amount cannot be negative');
}
}
public function amount(): float
{
return $this->amount;
}
public function add(Money $other): Money
{
return new Money($this->amount + $other->amount);
}
public function multiply(float $factor): Money
{
return new Money($this->amount * $factor);
}
}
enum OrderStatus: string
{
case PENDING = 'pending';
case PAID = 'paid';
case FAILED = 'failed';
case SHIPPED = 'shipped';
case DELIVERED = 'delivered';
}
enum CustomerType: string
{
case STANDARD = 'standard';
case PREMIUM = 'premium';
case VIP = 'vip';
}
/**
* PHP 8.4 Property Hooks and Asymmetric Visibility
*/
class Order
{
// Asymmetric visibility: public read, private write
public private(set) OrderId $id {
set {
if ($this->id !== null) {
throw new LogicException('Order ID cannot be changed once set');
}
$this->id = $value;
}
}
public private(set) OrderStatus $status = OrderStatus::PENDING;
public function __construct(
private readonly int $customerId,
private readonly Money $amount,
private readonly string $paymentMethod,
private readonly string $customerEmail,
private readonly CustomerType $customerType = CustomerType::STANDARD
) {}
public function getCustomerId(): int { return $this->customerId; }
public function getAmount(): Money { return $this->amount; }
public function getPaymentMethod(): string { return $this->paymentMethod; }
public function getCustomerEmail(): string { return $this->customerEmail; }
public function getCustomerType(): CustomerType { return $this->customerType; }
public function setId(OrderId $id): void
{
$this->id = $id;
}
public function updateStatus(OrderStatus $status): void
{
$this->status = $status;
}
}
/**
* FINAL CLASS: Order Processor using composition
* Cannot be extended - forces composition over inheritance
*/
final class OrderProcessor
{
public function __construct(
private readonly OrderValidatorInterface $validator,
private readonly TaxCalculatorInterface $taxCalculator,
private readonly OrderStorageInterface $storage,
private readonly PaymentGatewayInterface $paymentGateway,
private readonly NotificationServiceInterface $notificationService
) {}
public function processOrder(OrderData $orderData): ProcessingResult
{
// Validation
$validationResult = $this->validator->validate($orderData);
if (!$validationResult->isValid()) {
return ProcessingResult::failure($validationResult->getErrors());
}
// Create order
$order = new Order(
$orderData->customerId,
$orderData->amount,
$orderData->paymentMethod,
$orderData->customerEmail,
$orderData->customerType
);
// Calculate tax
$tax = $this->taxCalculator->calculateTax(
$order->getAmount(),
$order->getCustomerType()
);
$totalAmount = $order->getAmount()->add($tax);
// Save order
$orderId = $this->storage->saveOrder($order);
$order->setId($orderId);
// Process payment
$payment = new Payment($totalAmount, $order->getPaymentMethod());
$paymentResult = $this->paymentGateway->processPayment($payment);
if ($paymentResult->isSuccess()) {
$order->updateStatus(OrderStatus::PAID);
$this->storage->updateOrderStatus($orderId, OrderStatus::PAID);
$this->notificationService->sendOrderConfirmation(
$order->getCustomerEmail(),
$orderId
);
return ProcessingResult::success($orderId);
} else {
$order->updateStatus(OrderStatus::FAILED);
$this->storage->updateOrderStatus($orderId, OrderStatus::FAILED);
return ProcessingResult::failure(['Payment failed: ' . $paymentResult->getError()]);
}
}
}
/**
* Supporting classes and implementations
*/
readonly class OrderData
{
public function __construct(
public int $customerId,
public Money $amount,
public string $paymentMethod,
public string $customerEmail,
public CustomerType $customerType = CustomerType::STANDARD
) {}
}
readonly class Payment
{
public function __construct(
public Money $amount,
public string $method
) {}
}
class PaymentResult
{
public function __construct(
private readonly bool $success,
private readonly ?string $error = null
) {}
public function isSuccess(): bool { return $this->success; }
public function getError(): ?string { return $this->error; }
public static function success(): self
{
return new self(true);
}
public static function failure(string $error): self
{
return new self(false, $error);
}
}
class ValidationResult
{
public function __construct(
private readonly bool $valid,
private readonly array $errors = []
) {}
public function isValid(): bool { return $this->valid; }
public function getErrors(): array { return $this->errors; }
public static function valid(): self
{
return new self(true);
}
public static function invalid(array $errors): self
{
return new self(false, $errors);
}
}
class ProcessingResult
{
public function __construct(
private readonly bool $success,
private readonly ?OrderId $orderId = null,
private readonly array $errors = []
) {}
public function isSuccess(): bool { return $this->success; }
public function getOrderId(): ?OrderId { return $this->orderId; }
public function getErrors(): array { return $this->errors; }
public static function success(OrderId $orderId): self
{
return new self(true, $orderId);
}
public static function failure(array $errors): self
{
return new self(false, null, $errors);
}
}
/**
* Concrete Implementations - Can be easily swapped
*/
final class StandardTaxCalculator implements TaxCalculatorInterface
{
public function calculateTax(Money $amount, CustomerType $customerType): Money
{
$rate = match($customerType) {
CustomerType::STANDARD => 0.08,
CustomerType::PREMIUM => 0.05,
CustomerType::VIP => 0.03
};
return $amount->multiply($rate);
}
}
final class BasicOrderValidator implements OrderValidatorInterface
{
public function validate(OrderData $orderData): ValidationResult
{
$errors = [];
if ($orderData->customerId <= 0) {
$errors[] = 'Invalid customer ID';
}
if ($orderData->amount->amount() <= 0) {
$errors[] = 'Amount must be positive';
}
if (empty($orderData->paymentMethod)) {
$errors[] = 'Payment method required';
}
if (!filter_var($orderData->customerEmail, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Invalid email address';
}
return empty($errors)
? ValidationResult::valid()
: ValidationResult::invalid($errors);
}
}
/**
* In-memory implementation for testing
*/
final class InMemoryOrderStorage implements OrderStorageInterface
{
private array $orders = [];
private int $nextId = 1;
public function saveOrder(Order $order): OrderId
{
$orderId = new OrderId($this->nextId++);
$this->orders[$orderId->value()] = $order;
return $orderId;
}
public function updateOrderStatus(OrderId $orderId, OrderStatus $status): bool
{
if (isset($this->orders[$orderId->value()])) {
$this->orders[$orderId->value()]->updateStatus($status);
return true;
}
return false;
}
public function findOrder(OrderId $orderId): ?Order
{
return $this->orders[$orderId->value()] ?? null;
}
public function getOrderCount(): int
{
return count($this->orders);
}
}
/**
* MySQL implementation for production
*/
final class MySqlOrderStorage implements OrderStorageInterface
{
public function __construct(private readonly PDO $connection) {}
public function saveOrder(Order $order): OrderId
{
$sql = "INSERT INTO orders (customer_id, amount, payment_method, email, customer_type, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())";
$stmt = $this->connection->prepare($sql);
$stmt->execute([
$order->getCustomerId(),
$order->getAmount()->amount(),
$order->getPaymentMethod(),
$order->getCustomerEmail(),
$order->getCustomerType()->value,
$order->status->value
]);
return new OrderId((int) $this->connection->lastInsertId());
}
public function updateOrderStatus(OrderId $orderId, OrderStatus $status): bool
{
$sql = "UPDATE orders SET status = ? WHERE id = ?";
$stmt = $this->connection->prepare($sql);
return $stmt->execute([$status->value, $orderId->value()]);
}
public function findOrder(OrderId $orderId): ?Order
{
$sql = "SELECT * FROM orders WHERE id = ?";
$stmt = $this->connection->prepare($sql);
$stmt->execute([$orderId->value()]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
$order = new Order(
(int) $row['customer_id'],
new Money((float) $row['amount']),
$row['payment_method'],
$row['email'],
CustomerType::from($row['customer_type'])
);
$order->setId($orderId);
$order->updateStatus(OrderStatus::from($row['status']));
return $order;
}
}
Key Benefits of Final Classes with DIP
- Composition over inheritance: Final classes force you to inject dependencies rather than extending base classes
- Clear contracts: Interfaces define explicit contracts between components
- Easy testing: Dependencies can be easily swapped for testing
- Better encapsulation: Final classes prevent unwanted extension and maintain integrity
- PHP 8.4 optimizations: Final classes enable better opcache optimizations
The Testing Philosophy: Detroit vs London Schools
The testing community has long been divided between two approaches, historically known as the Detroit School (classical/state-based testing) and London School (mockist/interaction-based testing). However, modern practice has evolved toward a more pragmatic approach that combines both strategies.
Detroit School: Testing with Real Objects
The Detroit School, also known as the Classical approach, emphasizes:
- State-based verification: Test what the system produces, not how it produces it
- Real object usage: Use actual implementations when they're fast and deterministic
- Inside-out development: Build from the domain core outward
- Refactoring safety: Tests remain stable when implementation changes
London School: Testing with Mocks
The London School, or Mockist approach, focuses on:
- Interaction-based verification: Test how objects collaborate
- Heavy mocking: Mock all dependencies to isolate the system under test
- Outside-in development: Start from user interface and work toward domain
- Design feedback: Difficult mocking indicates poor design
The Pragmatic Approach: When to Use Each
Modern testing practice recognizes that both approaches have merit and should be used contextually:
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
/**
* PRAGMATIC TESTING: When to use real objects vs mocks
*
* Key Principles:
* 1. Use real objects when they're fast, deterministic, and have no side effects
* 2. Use mocks for external dependencies and when testing interactions
* 3. Combine both approaches based on what you're testing
* 4. Union types allow flexible testing strategies
*/
/**
* DETROIT SCHOOL: Testing with real objects
* Focus on state verification and end-to-end behavior
*/
class OrderProcessorDetroitTest extends TestCase
{
private OrderProcessor $processor;
private InMemoryOrderStorage $storage;
protected function setUp(): void
{
// Use REAL implementations for fast, deterministic dependencies
$this->storage = new InMemoryOrderStorage();
$validator = new BasicOrderValidator();
$taxCalculator = new StandardTaxCalculator();
// Mock only the external, slow, or non-deterministic dependencies
$paymentGateway = $this->createStub(PaymentGatewayInterface::class);
$paymentGateway->method('processPayment')
->willReturn(PaymentResult::success());
$notificationService = $this->createStub(NotificationServiceInterface::class);
$notificationService->method('sendOrderConfirmation')
->willReturn(true);
$this->processor = new OrderProcessor(
$validator, // REAL - fast and deterministic
$taxCalculator, // REAL - pure calculation
$this->storage, // REAL - in-memory, fast
$paymentGateway, // MOCK - external service
$notificationService // MOCK - external service
);
}
public function testProcessValidOrderSuccessfully(): void
{
$orderData = new OrderData(
customerId: 123,
amount: new Money(100.0),
paymentMethod: 'credit_card',
customerEmail: 'customer@example.com',
customerType: CustomerType::STANDARD
);
$result = $this->processor->processOrder($orderData);
// State-based verification using REAL storage
$this->assertTrue($result->isSuccess());
$this->assertEquals(1, $this->storage->getOrderCount());
$savedOrder = $this->storage->findOrder($result->getOrderId());
$this->assertEquals(OrderStatus::PAID, $savedOrder->status);
$this->assertEquals(123, $savedOrder->getCustomerId());
}
public function testProcessOrderWithInvalidData(): void
{
$orderData = new OrderData(
customerId: -1, // Invalid
amount: new Money(100.0),
paymentMethod: 'credit_card',
customerEmail: 'invalid-email', // Invalid
customerType: CustomerType::STANDARD
);
$result = $this->processor->processOrder($orderData);
// Real validator gives us real validation errors
$this->assertFalse($result->isSuccess());
$this->assertContains('Invalid customer ID', $result->getErrors());
$this->assertContains('Invalid email address', $result->getErrors());
$this->assertEquals(0, $this->storage->getOrderCount());
}
public function testTaxCalculationForDifferentCustomerTypes(): void
{
$baseAmount = new Money(100.0);
$standardOrderData = new OrderData(
customerId: 123,
amount: $baseAmount,
paymentMethod: 'credit_card',
customerEmail: 'standard@example.com',
customerType: CustomerType::STANDARD
);
$premiumOrderData = new OrderData(
customerId: 124,
amount: $baseAmount,
paymentMethod: 'credit_card',
customerEmail: 'premium@example.com',
customerType: CustomerType::PREMIUM
);
$this->processor->processOrder($standardOrderData);
$this->processor->processOrder($premiumOrderData);
// Using real tax calculator lets us verify actual calculations
$this->assertEquals(2, $this->storage->getOrderCount());
// Tax differences would be reflected in the stored orders
}
}
/**
* LONDON SCHOOL: Testing with mocks for interaction verification
* Focus on behavior verification and message passing
*/
class OrderProcessorLondonTest extends TestCase
{
private MockObject|OrderValidatorInterface $mockValidator;
private MockObject|TaxCalculatorInterface $mockTaxCalculator;
private MockObject|OrderStorageInterface $mockStorage;
private MockObject|PaymentGatewayInterface $mockPaymentGateway;
private MockObject|NotificationServiceInterface $mockNotificationService;
private OrderProcessor $processor;
protected function setUp(): void
{
// Mock ALL dependencies to focus on interactions
$this->mockValidator = $this->createMock(OrderValidatorInterface::class);
$this->mockTaxCalculator = $this->createMock(TaxCalculatorInterface::class);
$this->mockStorage = $this->createMock(OrderStorageInterface::class);
$this->mockPaymentGateway = $this->createMock(PaymentGatewayInterface::class);
$this->mockNotificationService = $this->createMock(NotificationServiceInterface::class);
$this->processor = new OrderProcessor(
$this->mockValidator,
$this->mockTaxCalculator,
$this->mockStorage,
$this->mockPaymentGateway,
$this->mockNotificationService
);
}
public function testProcessOrderFollowsCorrectSequence(): void
{
$orderData = new OrderData(
customerId: 123,
amount: new Money(100.0),
paymentMethod: 'credit_card',
customerEmail: 'test@example.com'
);
$orderId = new OrderId(1);
$tax = new Money(8.0);
$totalAmount = new Money(108.0);
// Setup expectations in the correct order
$this->mockValidator
->expects($this->once())
->method('validate')
->with($orderData)
->willReturn(ValidationResult::valid());
$this->mockTaxCalculator
->expects($this->once())
->method('calculateTax')
->with(
$this->equalTo($orderData->amount),
$this->equalTo($orderData->customerType)
)
->willReturn($tax);
$this->mockStorage
->expects($this->once())
->method('saveOrder')
->willReturn($orderId);
$this->mockPaymentGateway
->expects($this->once())
->method('processPayment')
->with($this->callback(function (Payment $payment) use ($totalAmount) {
return $payment->amount->amount() === $totalAmount->amount();
}))
->willReturn(PaymentResult::success());
$this->mockStorage
->expects($this->once())
->method('updateOrderStatus')
->with($orderId, OrderStatus::PAID)
->willReturn(true);
$this->mockNotificationService
->expects($this->once())
->method('sendOrderConfirmation')
->with('test@example.com', $orderId)
->willReturn(true);
$result = $this->processor->processOrder($orderData);
$this->assertTrue($result->isSuccess());
$this->assertEquals($orderId, $result->getOrderId());
}
public function testProcessOrderHandlesPaymentFailure(): void
{
$orderData = new OrderData(
customerId: 123,
amount: new Money(100.0),
paymentMethod: 'credit_card',
customerEmail: 'test@example.com'
);
$orderId = new OrderId(1);
$this->mockValidator
->method('validate')
->willReturn(ValidationResult::valid());
$this->mockTaxCalculator
->method('calculateTax')
->willReturn(new Money(8.0));
$this->mockStorage
->method('saveOrder')
->willReturn($orderId);
// Payment fails
$this->mockPaymentGateway
->method('processPayment')
->willReturn(PaymentResult::failure('Card declined'));
// Should update order status to failed
$this->mockStorage
->expects($this->once())
->method('updateOrderStatus')
->with($orderId, OrderStatus::FAILED);
// Should NOT send notification for failed payment
$this->mockNotificationService
->expects($this->never())
->method('sendOrderConfirmation');
$result = $this->processor->processOrder($orderData);
$this->assertFalse($result->isSuccess());
$this->assertContains('Payment failed: Card declined', $result->getErrors());
}
}
/**
* HYBRID APPROACH: Union types for flexible testing
* PHP 8.4 allows more sophisticated type unions
*/
// Define union type for testing flexibility
type TestableOrderStorage = InMemoryOrderStorage|MockObject;
type TestablePaymentGateway = PaymentGatewayInterface|MockObject;
class OrderProcessorHybridTest extends TestCase
{
/**
* Test with combination of real and mock objects
* Use real objects where they add value, mocks where necessary
*/
public function testCompleteOrderFlowWithHybridApproach(): void
{
// REAL objects for deterministic, fast operations
$validator = new BasicOrderValidator();
$taxCalculator = new StandardTaxCalculator();
$storage = new InMemoryOrderStorage();
// MOCK for external payment service
$paymentGateway = $this->createMock(PaymentGatewayInterface::class);
$paymentGateway->method('processPayment')
->willReturn(PaymentResult::success());
// FAKE for notification (simple test implementation)
$notificationService = new class implements NotificationServiceInterface {
public array $sentNotifications = [];
public function sendOrderConfirmation(string $email, OrderId $orderId): bool
{
$this->sentNotifications[] = ['email' => $email, 'orderId' => $orderId];
return true;
}
};
$processor = new OrderProcessor(
$validator,
$taxCalculator,
$storage,
$paymentGateway,
$notificationService
);
$orderData = new OrderData(
customerId: 123,
amount: new Money(100.0),
paymentMethod: 'credit_card',
customerEmail: 'hybrid@example.com',
customerType: CustomerType::PREMIUM
);
$result = $processor->processOrder($orderData);
// Verify using real storage
$this->assertTrue($result->isSuccess());
$this->assertEquals(1, $storage->getOrderCount());
// Verify using fake notification service
$this->assertCount(1, $notificationService->sentNotifications);
$this->assertEquals('hybrid@example.com', $notificationService->sentNotifications[0]['email']);
// Verify real tax calculation (5% for premium)
$savedOrder = $storage->findOrder($result->getOrderId());
$this->assertEquals(OrderStatus::PAID, $savedOrder->status);
// Could verify payment gateway interaction if needed
// This gives us the best of both worlds: real behavior testing
// with controlled external dependencies
}
/**
* Performance test using all real objects
* When speed matters, avoid mocks
*/
public function testHighThroughputProcessing(): void
{
$storage = new InMemoryOrderStorage();
$processor = new OrderProcessor(
new BasicOrderValidator(),
new StandardTaxCalculator(),
$storage,
new class implements PaymentGatewayInterface {
public function processPayment(Payment $payment): PaymentResult {
return PaymentResult::success(); // Always successful for speed
}
},
new class implements NotificationServiceInterface {
public function sendOrderConfirmation(string $email, OrderId $orderId): bool {
return true; // No-op for speed
}
}
);
$startTime = microtime(true);
// Process many orders quickly with real objects
for ($i = 0; $i < 1000; $i++) {
$orderData = new OrderData(
customerId: $i + 1,
amount: new Money(100.0 + $i),
paymentMethod: 'credit_card',
customerEmail: "customer{$i}@example.com"
);
$result = $processor->processOrder($orderData);
$this->assertTrue($result->isSuccess());
}
$endTime = microtime(true);
$processingTime = $endTime - $startTime;
$this->assertEquals(1000, $storage->getOrderCount());
$this->assertLessThan(1.0, $processingTime, 'Should process 1000 orders in under 1 second');
}
}
/**
* INTEGRATION TEST: Testing with database using real implementations
*/
class OrderProcessorIntegrationTest extends TestCase
{
private PDO $connection;
private OrderProcessor $processor;
protected function setUp(): void
{
// Use SQLite in-memory database for fast integration tests
$this->connection = new PDO('sqlite::memory:');
$this->connection->exec('
CREATE TABLE orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL,
amount DECIMAL(10,2) NOT NULL,
payment_method VARCHAR(50) NOT NULL,
email VARCHAR(255) NOT NULL,
customer_type VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
');
// Use REAL database storage for integration test
$storage = new MySqlOrderStorage($this->connection);
// Mock only truly external services
$paymentGateway = $this->createStub(PaymentGatewayInterface::class);
$paymentGateway->method('processPayment')->willReturn(PaymentResult::success());
$notificationService = $this->createStub(NotificationServiceInterface::class);
$notificationService->method('sendOrderConfirmation')->willReturn(true);
$this->processor = new OrderProcessor(
new BasicOrderValidator(),
new StandardTaxCalculator(),
$storage, // REAL database implementation
$paymentGateway,
$notificationService
);
}
public function testOrderPersistsToDatabase(): void
{
$orderData = new OrderData(
customerId: 123,
amount: new Money(100.0),
paymentMethod: 'credit_card',
customerEmail: 'integration@example.com',
customerType: CustomerType::STANDARD
);
$result = $this->processor->processOrder($orderData);
$this->assertTrue($result->isSuccess());
// Verify data in actual database
$stmt = $this->connection->query('SELECT * FROM orders');
$orders = $stmt->fetchAll(PDO::FETCH_ASSOC);
$this->assertCount(1, $orders);
$this->assertEquals(123, $orders[0]['customer_id']);
$this->assertEquals('paid', $orders[0]['status']);
$this->assertEquals('standard', $orders[0]['customer_type']);
}
}
Decision Matrix: Real Objects vs Mocks
Use Real Objects When | Use Mocks When |
---|---|
Fast to instantiate and execute | External dependencies (database, HTTP) |
Deterministic behavior | Non-deterministic behavior (random, time) |
No side effects | Testing error conditions |
Pure functions or simple state | Interaction verification is important |
Value objects and entities | Complex setup required |
TypeScript Patterns: Learning from Structural Typing
TypeScript offers valuable insights for PHP developers working with dependency inversion. While PHP uses nominal typing (class-based), TypeScript's structural typing provides interesting patterns we can adapt:
/**
* TypeScript Implementation: Dependency Inversion with Modern Patterns
*
* Key TypeScript features used:
* - Readonly classes (similar to PHP final classes)
* - Union types for flexible testing
* - Branded types for type safety
* - Discriminated unions for result types
* - Template literal types for strong typing
*/
// Branded types for stronger type safety
type OrderId = number & { readonly __brand: 'OrderId' };
type CustomerId = number & { readonly __brand: 'CustomerId' };
type Money = number & { readonly __brand: 'Money' };
// Helper functions for branded types
const createOrderId = (id: number): OrderId => {
if (id <= 0) throw new Error('Order ID must be positive');
return id as OrderId;
};
const createCustomerId = (id: number): CustomerId => {
if (id <= 0) throw new Error('Customer ID must be positive');
return id as CustomerId;
};
const createMoney = (amount: number): Money => {
if (amount < 0) throw new Error('Amount cannot be negative');
return amount as Money;
};
// Enums for type safety
enum OrderStatus {
PENDING = 'pending',
PAID = 'paid',
FAILED = 'failed',
SHIPPED = 'shipped',
DELIVERED = 'delivered',
}
enum CustomerType {
STANDARD = 'standard',
PREMIUM = 'premium',
VIP = 'vip',
}
// Result types using discriminated unions
type ValidationResult =
| { success: true; data: OrderData }
| { success: false; errors: string[] };
type ProcessingResult =
| { success: true; orderId: OrderId }
| { success: false; errors: string[] };
type PaymentResult =
| { success: true; transactionId: string }
| { success: false; error: string };
// Data structures
interface OrderData {
readonly customerId: CustomerId;
readonly amount: Money;
readonly paymentMethod: string;
readonly customerEmail: string;
readonly customerType: CustomerType;
}
interface Order extends OrderData {
readonly id: OrderId;
readonly status: OrderStatus;
readonly createdAt: Date;
}
interface Payment {
readonly amount: Money;
readonly method: string;
}
// Abstract interfaces (contracts)
interface OrderValidator {
validate(orderData: OrderData): ValidationResult;
}
interface TaxCalculator {
calculateTax(amount: Money, customerType: CustomerType): Money;
}
interface OrderStorage {
saveOrder(orderData: OrderData): Promise<OrderId>;
updateOrderStatus(orderId: OrderId, status: OrderStatus): Promise<boolean>;
findOrder(orderId: OrderId): Promise<Order | null>;
}
interface PaymentGateway {
processPayment(payment: Payment): Promise<PaymentResult>;
}
interface NotificationService {
sendOrderConfirmation(email: string, orderId: OrderId): Promise<boolean>;
}
/**
* MAIN CLASS: Final-like class using readonly pattern
* TypeScript doesn't have final classes, but readonly pattern achieves similar goals
*/
class OrderProcessor {
constructor(
private readonly validator: OrderValidator,
private readonly taxCalculator: TaxCalculator,
private readonly storage: OrderStorage,
private readonly paymentGateway: PaymentGateway,
private readonly notificationService: NotificationService
) {
// Make the class immutable by freezing it
Object.freeze(this);
}
async processOrder(orderData: OrderData): Promise<ProcessingResult> {
// Validation
const validationResult = this.validator.validate(orderData);
if (!validationResult.success) {
return { success: false, errors: validationResult.errors };
}
// Calculate tax
const tax = this.taxCalculator.calculateTax(
orderData.amount,
orderData.customerType
);
const totalAmount = createMoney(orderData.amount + tax);
try {
// Save order
const orderId = await this.storage.saveOrder(orderData);
// Process payment
const payment: Payment = {
amount: totalAmount,
method: orderData.paymentMethod,
};
const paymentResult = await this.paymentGateway.processPayment(payment);
if (paymentResult.success) {
await this.storage.updateOrderStatus(orderId, OrderStatus.PAID);
await this.notificationService.sendOrderConfirmation(
orderData.customerEmail,
orderId
);
return { success: true, orderId };
} else {
await this.storage.updateOrderStatus(orderId, OrderStatus.FAILED);
return {
success: false,
errors: [`Payment failed: ${paymentResult.error}`]
};
}
} catch (error) {
return {
success: false,
errors: [`Processing error: ${error instanceof Error ? error.message : 'Unknown error'}`]
};
}
}
}
/**
* Concrete Implementations
*/
// Readonly class for tax calculation
class StandardTaxCalculator implements TaxCalculator {
private readonly taxRates = {
[CustomerType.STANDARD]: 0.08,
[CustomerType.PREMIUM]: 0.05,
[CustomerType.VIP]: 0.03,
} as const;
constructor() {
Object.freeze(this);
}
calculateTax(amount: Money, customerType: CustomerType): Money {
const rate = this.taxRates[customerType];
return createMoney(amount * rate);
}
}
class BasicOrderValidator implements OrderValidator {
constructor() {
Object.freeze(this);
}
validate(orderData: OrderData): ValidationResult {
const errors: string[] = [];
if (orderData.customerId <= 0) {
errors.push('Invalid customer ID');
}
if (orderData.amount <= 0) {
errors.push('Amount must be positive');
}
if (!orderData.paymentMethod.trim()) {
errors.push('Payment method required');
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(orderData.customerEmail)) {
errors.push('Invalid email address');
}
return errors.length === 0
? { success: true, data: orderData }
: { success: false, errors };
}
}
/**
* In-memory implementation for testing
*/
class InMemoryOrderStorage implements OrderStorage {
private orders = new Map<OrderId, Order>();
private nextId = 1;
constructor() {
Object.freeze(this);
}
async saveOrder(orderData: OrderData): Promise<OrderId> {
const orderId = createOrderId(this.nextId++);
const order: Order = {
...orderData,
id: orderId,
status: OrderStatus.PENDING,
createdAt: new Date(),
};
this.orders.set(orderId, order);
return orderId;
}
async updateOrderStatus(orderId: OrderId, status: OrderStatus): Promise<boolean> {
const order = this.orders.get(orderId);
if (!order) return false;
this.orders.set(orderId, { ...order, status });
return true;
}
async findOrder(orderId: OrderId): Promise<Order | null> {
return this.orders.get(orderId) || null;
}
getOrderCount(): number {
return this.orders.size;
}
getAllOrders(): Order[] {
return Array.from(this.orders.values());
}
}
/**
* External service implementations
*/
class StripePaymentGateway implements PaymentGateway {
constructor(private readonly apiKey: string) {
Object.freeze(this);
}
async processPayment(payment: Payment): Promise<PaymentResult> {
// Simulate API call
try {
// In real implementation, this would call Stripe API
const success = Math.random() > 0.1; // 90% success rate
if (success) {
return {
success: true,
transactionId: `txn_${Date.now()}`,
};
} else {
return {
success: false,
error: 'Card declined',
};
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Payment processing error',
};
}
}
}
class EmailNotificationService implements NotificationService {
constructor(private readonly smtpConfig: { host: string; port: number }) {
Object.freeze(this);
}
async sendOrderConfirmation(email: string, orderId: OrderId): Promise<boolean> {
// Simulate email sending
try {
console.log(`Sending confirmation to ${email} for order ${orderId}`);
return true;
} catch (error) {
console.error('Failed to send notification:', error);
return false;
}
}
}
/**
* TESTING PATTERNS: Union types for flexible testing
*/
// Union types allow mixing real and mock objects
type TestableOrderStorage = InMemoryOrderStorage | jest.Mocked<OrderStorage>;
type TestablePaymentGateway = StripePaymentGateway | jest.Mocked<PaymentGateway>;
/**
* Test helper functions
*/
function createTestOrderData(): OrderData {
return {
customerId: createCustomerId(123),
amount: createMoney(100.0),
paymentMethod: 'credit_card',
customerEmail: 'test@example.com',
customerType: CustomerType.STANDARD,
};
}
function createOrderProcessor(
overrides: Partial<{
validator: OrderValidator;
taxCalculator: TaxCalculator;
storage: OrderStorage;
paymentGateway: PaymentGateway;
notificationService: NotificationService;
}> = {}
): OrderProcessor {
const defaults = {
validator: new BasicOrderValidator(),
taxCalculator: new StandardTaxCalculator(),
storage: new InMemoryOrderStorage(),
paymentGateway: new StripePaymentGateway('test_key'),
notificationService: new EmailNotificationService({ host: 'localhost', port: 587 }),
};
return new OrderProcessor(
overrides.validator || defaults.validator,
overrides.taxCalculator || defaults.taxCalculator,
overrides.storage || defaults.storage,
overrides.paymentGateway || defaults.paymentGateway,
overrides.notificationService || defaults.notificationService
);
}
/**
* Example test cases showing pragmatic testing approach
*/
// Detroit School: Use real objects when possible
async function testWithRealObjects() {
const storage = new InMemoryOrderStorage();
const processor = createOrderProcessor({ storage });
const orderData = createTestOrderData();
const result = await processor.processOrder(orderData);
if (result.success) {
console.log('✓ Order processed successfully');
console.log('✓ Order count:', storage.getOrderCount());
const savedOrder = await storage.findOrder(result.orderId);
console.log('✓ Order status:', savedOrder?.status);
} else {
console.log('✗ Order processing failed:', result.errors);
}
}
// London School: Mock for interaction testing
async function testWithMocks() {
const mockPaymentGateway: jest.Mocked<PaymentGateway> = {
processPayment: jest.fn(),
};
const mockNotificationService: jest.Mocked<NotificationService> = {
sendOrderConfirmation: jest.fn(),
};
mockPaymentGateway.processPayment.mockResolvedValue({
success: true,
transactionId: 'test_txn_123',
});
mockNotificationService.sendOrderConfirmation.mockResolvedValue(true);
const processor = createOrderProcessor({
paymentGateway: mockPaymentGateway,
notificationService: mockNotificationService,
});
const orderData = createTestOrderData();
const result = await processor.processOrder(orderData);
if (result.success) {
console.log('✓ Mocked order processed successfully');
console.log('✓ Payment gateway called:', mockPaymentGateway.processPayment.mock.calls.length);
console.log('✓ Notification sent:', mockNotificationService.sendOrderConfirmation.mock.calls.length);
}
}
// Hybrid Approach: Mix real and mock objects
async function testHybridApproach() {
const realStorage = new InMemoryOrderStorage();
const mockPaymentGateway: jest.Mocked<PaymentGateway> = {
processPayment: jest.fn().mockResolvedValue({
success: true,
transactionId: 'hybrid_txn_456',
}),
};
const processor = createOrderProcessor({
storage: realStorage, // Real - fast and deterministic
paymentGateway: mockPaymentGateway, // Mock - external service
});
const orderData = createTestOrderData();
const result = await processor.processOrder(orderData);
if (result.success) {
console.log('✓ Hybrid test passed');
console.log('✓ Real storage used, order count:', realStorage.getOrderCount());
console.log('✓ Mock payment gateway called');
const savedOrder = await realStorage.findOrder(result.orderId);
console.log('✓ Real order status:', savedOrder?.status);
}
}
/**
* Advanced TypeScript patterns for dependency inversion
*/
// Generic factory pattern for creating processors with different configurations
type ProcessorConfig<T extends Record<string, unknown>> = {
[K in keyof T]: T[K];
};
function createConfigurableProcessor<TConfig extends {
validator?: OrderValidator;
taxCalculator?: TaxCalculator;
storage?: OrderStorage;
paymentGateway?: PaymentGateway;
notificationService?: NotificationService;
}>(config: TConfig): OrderProcessor {
return new OrderProcessor(
config.validator || new BasicOrderValidator(),
config.taxCalculator || new StandardTaxCalculator(),
config.storage || new InMemoryOrderStorage(),
config.paymentGateway || new StripePaymentGateway('default_key'),
config.notificationService || new EmailNotificationService({ host: 'localhost', port: 587 })
);
}
// Template literal types for strongly typed configurations
type Environment = 'development' | 'testing' | 'production';
type ServiceConfig<T extends Environment> = T extends 'testing'
? { useInMemoryStorage: true; mockExternalServices: true }
: T extends 'development'
? { useInMemoryStorage: false; mockExternalServices: false; debugMode: true }
: { useInMemoryStorage: false; mockExternalServices: false; optimizeForProduction: true };
function createEnvironmentSpecificProcessor<T extends Environment>(
env: T,
config: ServiceConfig<T>
): OrderProcessor {
// Type-safe configuration based on environment
if (env === 'testing') {
return createConfigurableProcessor({
storage: new InMemoryOrderStorage(),
// Mock other services in testing
});
}
// Production or development configuration
return createConfigurableProcessor({
// Real implementations
});
}
export {
OrderProcessor,
StandardTaxCalculator,
BasicOrderValidator,
InMemoryOrderStorage,
StripePaymentGateway,
EmailNotificationService,
createTestOrderData,
createOrderProcessor,
createConfigurableProcessor,
testWithRealObjects,
testWithMocks,
testHybridApproach,
};
export type {
OrderId,
CustomerId,
Money,
OrderData,
Order,
ValidationResult,
ProcessingResult,
PaymentResult,
OrderValidator,
TaxCalculator,
OrderStorage,
PaymentGateway,
NotificationService,
TestableOrderStorage,
TestablePaymentGateway,
};
Union Types for Flexible Testing
The TypeScript code above demonstrates several advanced patterns that PHP developers can learn from, including branded types for stronger type safety, discriminated unions for result types, and template literal types for advanced configurations.
TypeScript's union types inspire a flexible testing approach where the same interface can accommodate both real implementations and mocks. While PHP doesn't have union types in the same way, we can achieve similar flexibility through careful interface design.
The key insight from TypeScript is that testing interfaces should be designed to accommodate both real and mock implementations naturally, rather than forcing a choice between approaches. The readonly pattern in TypeScript also provides inspiration for creating immutable dependencies in PHP.
Infrastructure as Code: Ansible and Dependency Inversion
Dependency inversion principles extend beyond application code to infrastructure automation. Ansible playbooks can demonstrate these concepts at the infrastructure level:
---
# Ansible Playbook: Dependency Inversion in Infrastructure Automation
#
# This playbook demonstrates dependency inversion principles in infrastructure:
# - Abstract roles define interfaces (what should be done)
# - Concrete implementations handle specifics (how it's done)
# - Environment-specific variables control behavior
# - Composition over inheritance through role dependencies
# Main playbook that orchestrates the deployment
- name: Deploy order processing application with dependency inversion
hosts: web_servers
become: yes
vars:
# Abstract configuration - what we want, not how to achieve it
app_name: order-processor
app_version: "{{ lookup('env', 'APP_VERSION') | default('latest') }}"
environment: "{{ lookup('env', 'ENVIRONMENT') | default('development') }}"
# Dependency inversion: depend on abstractions, not concretions
database_type: "{{ database_config.type }}"
cache_type: "{{ cache_config.type }}"
queue_type: "{{ queue_config.type }}"
# Environment-specific configurations (injected dependencies)
database_config:
type: "{{ 'mysql' if environment == 'production' else 'sqlite' }}"
host: "{{ 'db.prod.example.com' if environment == 'production' else 'localhost' }}"
port: "{{ 3306 if environment == 'production' else 3306 }}"
name: "{{ app_name }}_{{ environment }}"
cache_config:
type: "{{ 'redis' if environment in ['production', 'staging'] else 'memory' }}"
host: "{{ 'cache.prod.example.com' if environment == 'production' else 'localhost' }}"
port: 6379
queue_config:
type: "{{ 'rabbitmq' if environment == 'production' else 'database' }}"
host: "{{ 'queue.prod.example.com' if environment == 'production' else 'localhost' }}"
port: 5672
# Role composition - dependency injection for infrastructure
roles:
# Core roles (always needed - stable dependencies)
- role: system_preparation
tags: [system, always]
- role: php_runtime
php_version: "8.4"
tags: [php, runtime]
# Conditional roles based on environment (injected dependencies)
- role: database_service
database_implementation: "{{ database_type }}"
when: database_type is defined
tags: [database]
- role: cache_service
cache_implementation: "{{ cache_type }}"
when: cache_type != 'memory'
tags: [cache]
- role: queue_service
queue_implementation: "{{ queue_type }}"
when: queue_type != 'database'
tags: [queue]
# Application deployment (depends on abstractions above)
- role: application_deployment
tags: [app, deploy]
# Monitoring and health checks (final layer)
- role: monitoring
when: environment in ['staging', 'production']
tags: [monitoring]
# Post-deployment tasks
post_tasks:
- name: Verify application health
uri:
url: "http://localhost:8080/health"
method: GET
status_code: 200
register: health_check
retries: 5
delay: 10
- name: Display deployment summary
debug:
msg: |
Deployment completed successfully:
- Application: {{ app_name }} v{{ app_version }}
- Environment: {{ environment }}
- Database: {{ database_type }} on {{ database_config.host }}
- Cache: {{ cache_type }}
- Queue: {{ queue_type }}
- Health check: {{ 'PASSED' if health_check.status == 200 else 'FAILED' }}
---
# Database Service Role - Implements database abstraction
# roles/database_service/tasks/main.yml
- name: Include database-specific tasks
include_tasks: "{{ database_implementation }}.yml"
when: database_implementation is defined
---
# MySQL implementation - roles/database_service/tasks/mysql.yml
- name: Install MySQL server
package:
name: "{{ mysql_package_name }}"
state: present
vars:
mysql_package_name: "{{ 'mysql-server' if ansible_os_family == 'Debian' else 'mysql-community-server' }}"
- name: Configure MySQL for high performance
template:
src: mysql.cnf.j2
dest: /etc/mysql/mysql.conf.d/99-custom.cnf
backup: yes
notify: restart mysql
vars:
# PHP 8.4 optimized MySQL configuration
mysql_config:
innodb_buffer_pool_size: "{{ (ansible_memtotal_mb * 0.7) | int }}M"
innodb_log_file_size: "256M"
max_connections: "{{ 500 if environment == 'production' else 100 }}"
query_cache_size: "{{ '256M' if environment == 'production' else '64M' }}"
- name: Create application database
mysql_db:
name: "{{ database_config.name }}"
state: present
encoding: utf8mb4
collation: utf8mb4_unicode_ci
notify: reload mysql privileges
- name: Create database user for application
mysql_user:
name: "{{ app_name }}"
password: "{{ database_password | default(lookup('password', '/dev/null chars=ascii_letters,digits length=32')) }}"
priv: "{{ database_config.name }}.*:ALL"
host: "{{ 'localhost' if database_config.host == 'localhost' else '%' }}"
state: present
notify: reload mysql privileges
---
# SQLite implementation - roles/database_service/tasks/sqlite.yml
- name: Install SQLite
package:
name: sqlite3
state: present
- name: Create SQLite database directory
file:
path: /var/lib/{{ app_name }}/database
state: directory
owner: www-data
group: www-data
mode: '0750'
- name: Initialize SQLite database
command: sqlite3 /var/lib/{{ app_name }}/database/{{ database_config.name }}.db "SELECT 1;"
args:
creates: /var/lib/{{ app_name }}/database/{{ database_config.name }}.db
become_user: www-data
---
# Cache Service Role - Implements caching abstraction
# roles/cache_service/tasks/main.yml
- name: Include cache-specific tasks
include_tasks: "{{ cache_implementation }}.yml"
when: cache_implementation is defined and cache_implementation != 'memory'
---
# Redis implementation - roles/cache_service/tasks/redis.yml
- name: Install Redis server
package:
name: redis-server
state: present
- name: Configure Redis for application caching
template:
src: redis.conf.j2
dest: /etc/redis/redis.conf
backup: yes
notify: restart redis
vars:
redis_config:
maxmemory: "{{ (ansible_memtotal_mb * 0.2) | int }}mb"
maxmemory_policy: "allkeys-lru"
save: "900 1 300 10 60 10000" # Persistence configuration
tcp_keepalive: 300
timeout: 0
- name: Start and enable Redis service
systemd:
name: redis-server
state: started
enabled: yes
---
# Application Deployment Role - Uses injected dependencies
# roles/application_deployment/tasks/main.yml
- name: Create application directory structure
file:
path: "{{ item }}"
state: directory
owner: www-data
group: www-data
mode: '0755'
loop:
- /var/www/{{ app_name }}
- /var/www/{{ app_name }}/storage
- /var/www/{{ app_name }}/config
- /var/log/{{ app_name }}
- name: Deploy application configuration
template:
src: app-config.php.j2
dest: /var/www/{{ app_name }}/config/config.php
owner: www-data
group: www-data
mode: '0640'
vars:
# Configuration uses abstract dependencies
config:
database:
driver: "{{ database_type }}"
host: "{{ database_config.host }}"
port: "{{ database_config.port }}"
name: "{{ database_config.name }}"
username: "{{ app_name }}"
cache:
driver: "{{ cache_type }}"
host: "{{ cache_config.host if cache_type != 'memory' else 'localhost' }}"
port: "{{ cache_config.port if cache_type != 'memory' else null }}"
queue:
driver: "{{ queue_type }}"
host: "{{ queue_config.host if queue_type != 'database' else null }}"
port: "{{ queue_config.port if queue_type != 'database' else null }}"
app:
environment: "{{ environment }}"
debug: "{{ environment in ['development', 'testing'] }}"
log_level: "{{ 'debug' if environment == 'development' else 'warning' }}"
- name: Deploy PHP 8.4 application with dependency injection
template:
src: bootstrap.php.j2
dest: /var/www/{{ app_name }}/bootstrap.php
owner: www-data
group: www-data
mode: '0644'
- name: Install Composer dependencies
composer:
command: install
working_dir: /var/www/{{ app_name }}
no_dev: "{{ environment == 'production' }}"
become_user: www-data
- name: Run database migrations
shell: |
cd /var/www/{{ app_name }}
php artisan migrate --force
become_user: www-data
when: database_type is defined
---
# Testing Role - Demonstrates testing different configurations
# roles/application_testing/tasks/main.yml
- name: Run unit tests with real dependencies (Detroit school)
shell: |
cd /var/www/{{ app_name }}
./vendor/bin/phpunit --testsuite=unit --filter=DetroitTest
become_user: www-data
register: unit_test_results
when: environment in ['development', 'testing']
- name: Run integration tests with mocked external services (London school)
shell: |
cd /var/www/{{ app_name }}
./vendor/bin/phpunit --testsuite=integration --filter=LondonTest
become_user: www-data
register: integration_test_results
when: environment in ['development', 'testing']
- name: Run hybrid tests (pragmatic approach)
shell: |
cd /var/www/{{ app_name }}
./vendor/bin/phpunit --testsuite=hybrid
become_user: www-data
register: hybrid_test_results
when: environment in ['development', 'testing']
- name: Display test results
debug:
msg: |
Test Results Summary:
- Unit Tests (Detroit): {{ 'PASSED' if unit_test_results.rc == 0 else 'FAILED' }}
- Integration Tests (London): {{ 'PASSED' if integration_test_results.rc == 0 else 'FAILED' }}
- Hybrid Tests: {{ 'PASSED' if hybrid_test_results.rc == 0 else 'FAILED' }}
when: environment in ['development', 'testing']
---
# Error Handling and Rollback (Defensive Infrastructure)
# This demonstrates fail-fast principles in infrastructure
- name: Validate configuration before deployment
block:
- name: Check PHP 8.4 compatibility
shell: php -v | grep "PHP 8.4"
register: php_version_check
failed_when: php_version_check.rc != 0
- name: Validate database connectivity
shell: |
php -r "
try {
{% if database_type == 'mysql' %}
new PDO('mysql:host={{ database_config.host }};port={{ database_config.port }}', 'root', '');
{% else %}
new PDO('sqlite:/var/lib/{{ app_name }}/database/test.db');
{% endif %}
echo 'OK';
} catch (Exception \$e) {
echo 'FAILED: ' . \$e->getMessage();
exit(1);
}"
register: db_connectivity_check
- name: Validate cache connectivity
shell: |
{% if cache_type == 'redis' %}
redis-cli -h {{ cache_config.host }} -p {{ cache_config.port }} ping
{% else %}
echo "PONG" # Memory cache always available
{% endif %}
register: cache_connectivity_check
when: cache_type != 'memory'
rescue:
- name: Rollback on validation failure
debug:
msg: |
Validation failed - Rolling back deployment:
- PHP Version: {{ php_version_check.stdout | default('FAILED') }}
- Database: {{ db_connectivity_check.stdout | default('FAILED') }}
- Cache: {{ cache_connectivity_check.stdout | default('SKIPPED') }}
- name: Stop deployment
fail:
msg: "Pre-deployment validation failed. Deployment aborted."
---
# Handlers file - roles/database_service/handlers/main.yml
- name: restart mysql
systemd:
name: mysql
state: restarted
- name: reload mysql privileges
mysql_query:
query: "FLUSH PRIVILEGES"
- name: restart redis
systemd:
name: redis-server
state: restarted
Infrastructure Testing Strategies
Just as in application testing, infrastructure code benefits from pragmatic testing approaches:
- Use real tools for deterministic operations (file creation, package installation)
- Mock external services when testing deployment scripts
- Integration tests with containers or VMs for complete workflows
- Fail-fast validation to catch configuration errors early
Bash Scripting: Dependency Injection in Shell Scripts
Even shell scripts can benefit from dependency inversion principles. Here's how to apply these concepts in Bash:
#!/bin/bash
# Bash Script: Dependency Inversion and Testing Patterns in Shell Scripting
#
# This script demonstrates:
# - Dependency injection through environment variables and configuration
# - Abstract interfaces through function contracts
# - Fail-fast error handling
# - Pragmatic testing approach: real tools vs mocked functions
# - Composition over inheritance through modular functions
set -euo pipefail # Fail fast: exit on error, undefined vars, pipe failures
IFS=$'\n\t' # Secure Internal Field Separator
# Global configuration - dependency injection through environment
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly PROJECT_ROOT="${PROJECT_ROOT:-$(dirname "$SCRIPT_DIR")}"
readonly ENVIRONMENT="${ENVIRONMENT:-development}"
readonly PHP_VERSION="${PHP_VERSION:-8.4}"
readonly LOG_LEVEL="${LOG_LEVEL:-info}"
# Color codes for output
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly BLUE='\033[0;34m'
readonly NC='\033[0m' # No Color
# Abstract logging interface - dependency inversion principle
log() {
local level="$1"
shift
local message="$*"
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
# Delegate to appropriate logging implementation
case "${LOG_LEVEL}" in
"debug")
_log_debug "$timestamp" "$level" "$message"
;;
"info")
_log_info "$timestamp" "$level" "$message"
;;
"warning")
_log_warning "$timestamp" "$level" "$message"
;;
*)
_log_error "$timestamp" "$level" "$message"
;;
esac
}
# Concrete logging implementations
_log_debug() {
local timestamp="$1" level="$2" message="$3"
echo -e "${BLUE}[$timestamp] [$level] $message${NC}" >&2
}
_log_info() {
local timestamp="$1" level="$2" message="$3"
case "$level" in
"ERROR") echo -e "${RED}[$timestamp] [$level] $message${NC}" >&2 ;;
"WARN") echo -e "${YELLOW}[$timestamp] [$level] $message${NC}" >&2 ;;
*) echo -e "${GREEN}[$timestamp] [$level] $message${NC}" ;;
esac
}
_log_warning() {
local timestamp="$1" level="$2" message="$3"
[[ "$level" =~ ^(ERROR|WARN)$ ]] && echo -e "${RED}[$timestamp] [$level] $message${NC}" >&2
}
_log_error() {
local timestamp="$1" level="$2" message="$3"
[[ "$level" == "ERROR" ]] && echo -e "${RED}[$timestamp] [$level] $message${NC}" >&2
}
# Error handling with dependency inversion
error_handler() {
local exit_code=$?
local line_number=$1
log "ERROR" "Script failed at line $line_number with exit code $exit_code"
# Delegate to appropriate error handler based on environment
case "${ENVIRONMENT}" in
"development")
_handle_development_error "$exit_code" "$line_number"
;;
"production")
_handle_production_error "$exit_code" "$line_number"
;;
*)
_handle_generic_error "$exit_code" "$line_number"
;;
esac
exit "$exit_code"
}
trap 'error_handler $LINENO' ERR
# Abstract interface for database operations
database_execute() {
local query="$1"
local database_type="${DATABASE_TYPE:-mysql}"
log "DEBUG" "Executing query with $database_type: $query"
# Dependency inversion: delegate to appropriate implementation
case "$database_type" in
"mysql")
_execute_mysql_query "$query"
;;
"sqlite")
_execute_sqlite_query "$query"
;;
"test")
_execute_test_query "$query" # For testing
;;
*)
log "ERROR" "Unsupported database type: $database_type"
return 1
;;
esac
}
# Concrete database implementations
_execute_mysql_query() {
local query="$1"
local mysql_host="${MYSQL_HOST:-localhost}"
local mysql_user="${MYSQL_USER:-root}"
local mysql_db="${MYSQL_DB:-order_processor}"
# Fail fast: validate connection before executing
if ! mysql -h "$mysql_host" -u "$mysql_user" -e "SELECT 1" &>/dev/null; then
log "ERROR" "Cannot connect to MySQL at $mysql_host"
return 1
fi
mysql -h "$mysql_host" -u "$mysql_user" -D "$mysql_db" -e "$query"
}
_execute_sqlite_query() {
local query="$1"
local sqlite_db="${SQLITE_DB:-/tmp/order_processor.db}"
# Fail fast: check if database file exists or can be created
if [[ ! -f "$sqlite_db" ]] && ! touch "$sqlite_db" 2>/dev/null; then
log "ERROR" "Cannot access SQLite database at $sqlite_db"
return 1
fi
sqlite3 "$sqlite_db" "$query"
}
_execute_test_query() {
local query="$1"
log "DEBUG" "Test query executed: $query"
echo "test_result"
}
# Abstract interface for payment processing
process_payment() {
local amount="$1"
local payment_method="$2"
local payment_gateway="${PAYMENT_GATEWAY:-stripe}"
log "INFO" "Processing payment of \$$amount via $payment_method using $payment_gateway"
# Dependency inversion: delegate to appropriate gateway
case "$payment_gateway" in
"stripe")
_process_stripe_payment "$amount" "$payment_method"
;;
"paypal")
_process_paypal_payment "$amount" "$payment_method"
;;
"test")
_process_test_payment "$amount" "$payment_method"
;;
*)
log "ERROR" "Unsupported payment gateway: $payment_gateway"
return 1
;;
esac
}
# Concrete payment implementations
_process_stripe_payment() {
local amount="$1" payment_method="$2"
local stripe_key="${STRIPE_SECRET_KEY:-}"
if [[ -z "$stripe_key" ]]; then
log "ERROR" "Stripe secret key not configured"
return 1
fi
# Simulate API call
if curl -s -X POST "https://api.stripe.com/v1/charges" \
-H "Authorization: Bearer $stripe_key" \
-d "amount=$((${amount%.*} * 100))" \
-d "currency=usd" \
-d "source=$payment_method" &>/dev/null; then
log "INFO" "Stripe payment successful"
return 0
else
log "ERROR" "Stripe payment failed"
return 1
fi
}
_process_paypal_payment() {
local amount="$1" payment_method="$2"
log "INFO" "PayPal payment processing: \$$amount"
# PayPal implementation would go here
return 0
}
_process_test_payment() {
local amount="$1" payment_method="$2"
log "DEBUG" "Test payment processed: \$$amount via $payment_method"
# Simulate success/failure for testing
local success_rate="${TEST_PAYMENT_SUCCESS_RATE:-90}"
local random=$((RANDOM % 100))
if (( random < success_rate )); then
log "DEBUG" "Test payment successful (random: $random, threshold: $success_rate)"
return 0
else
log "DEBUG" "Test payment failed (random: $random, threshold: $success_rate)"
return 1
fi
}
# Abstract notification interface
send_notification() {
local recipient="$1"
local subject="$2"
local message="$3"
local notification_service="${NOTIFICATION_SERVICE:-email}"
log "INFO" "Sending notification to $recipient via $notification_service"
case "$notification_service" in
"email")
_send_email_notification "$recipient" "$subject" "$message"
;;
"slack")
_send_slack_notification "$recipient" "$subject" "$message"
;;
"test")
_send_test_notification "$recipient" "$subject" "$message"
;;
*)
log "ERROR" "Unsupported notification service: $notification_service"
return 1
;;
esac
}
# Concrete notification implementations
_send_email_notification() {
local recipient="$1" subject="$2" message="$3"
if command -v mail >/dev/null 2>&1; then
echo "$message" | mail -s "$subject" "$recipient"
log "INFO" "Email sent successfully to $recipient"
else
log "WARN" "Mail command not available, notification not sent"
return 1
fi
}
_send_slack_notification() {
local recipient="$1" subject="$2" message="$3"
local slack_webhook="${SLACK_WEBHOOK:-}"
if [[ -z "$slack_webhook" ]]; then
log "ERROR" "Slack webhook not configured"
return 1
fi
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"$subject: $message\"}" \
"$slack_webhook" &>/dev/null
}
_send_test_notification() {
local recipient="$1" subject="$2" message="$3"
log "DEBUG" "Test notification: To=$recipient, Subject=$subject, Message=$message"
}
# Main business logic using dependency injection
process_order() {
local customer_id="$1"
local amount="$2"
local payment_method="$3"
local email="$4"
log "INFO" "Processing order: customer=$customer_id, amount=\$$amount"
# Validate inputs (fail fast)
if [[ ! "$customer_id" =~ ^[0-9]+$ ]] || (( customer_id <= 0 )); then
log "ERROR" "Invalid customer ID: $customer_id"
return 1
fi
if [[ ! "$amount" =~ ^[0-9]+(\.[0-9]{1,2})?$ ]] || (( $(echo "$amount <= 0" | bc -l) )); then
log "ERROR" "Invalid amount: $amount"
return 1
fi
if [[ ! "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
log "ERROR" "Invalid email address: $email"
return 1
fi
# Calculate tax using environment-specific rate
local tax_rate
case "${CUSTOMER_TYPE:-standard}" in
"premium") tax_rate="0.05" ;;
"vip") tax_rate="0.03" ;;
*) tax_rate="0.08" ;;
esac
local tax
tax=$(echo "$amount * $tax_rate" | bc -l)
local total
total=$(echo "$amount + $tax" | bc -l)
log "INFO" "Order total: \$${amount} + \$${tax} tax = \$${total}"
# Save order to database (using injected database dependency)
local order_id
if ! order_id=$(database_execute "INSERT INTO orders (customer_id, amount, status) VALUES ($customer_id, $total, 'pending'); SELECT last_insert_rowid();"); then
log "ERROR" "Failed to save order to database"
return 1
fi
log "INFO" "Order saved with ID: $order_id"
# Process payment (using injected payment gateway)
if process_payment "$total" "$payment_method"; then
# Update order status
if database_execute "UPDATE orders SET status = 'paid' WHERE id = $order_id"; then
log "INFO" "Order $order_id marked as paid"
# Send confirmation (using injected notification service)
if send_notification "$email" "Order Confirmation" "Your order #$order_id has been processed successfully."; then
log "INFO" "Order processing completed successfully"
return 0
else
log "WARN" "Order processed but notification failed"
return 0 # Don't fail the order for notification issues
fi
else
log "ERROR" "Failed to update order status"
return 1
fi
else
# Payment failed - update order status
database_execute "UPDATE orders SET status = 'failed' WHERE id = $order_id"
log "ERROR" "Payment processing failed for order $order_id"
return 1
fi
}
# Testing functions - demonstrating different testing approaches
# Detroit School: Use real implementations where possible
test_with_real_implementations() {
log "INFO" "Running Detroit School tests with real implementations"
# Use SQLite (real but lightweight) instead of MySQL
export DATABASE_TYPE="sqlite"
export SQLITE_DB="/tmp/test_orders_real.db"
# Use test payment gateway but real-ish logic
export PAYMENT_GATEWAY="test"
export TEST_PAYMENT_SUCCESS_RATE="100"
# Use test notifications but with real message formatting
export NOTIFICATION_SERVICE="test"
# Initialize test database
database_execute "CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER, amount REAL, status TEXT);"
database_execute "DELETE FROM orders;" # Clean state
# Test valid order
if process_order 123 "100.00" "credit_card" "test@example.com"; then
log "INFO" "✓ Valid order test passed"
else
log "ERROR" "✗ Valid order test failed"
return 1
fi
# Verify order was actually saved
local order_count
order_count=$(database_execute "SELECT COUNT(*) FROM orders WHERE status = 'paid';")
if [[ "$order_count" == "1" ]]; then
log "INFO" "✓ Database persistence test passed"
else
log "ERROR" "✗ Database persistence test failed (count: $order_count)"
return 1
fi
# Test invalid inputs (should fail fast)
if ! process_order -1 "100.00" "credit_card" "invalid-email"; then
log "INFO" "✓ Input validation test passed"
else
log "ERROR" "✗ Input validation test failed"
return 1
fi
log "INFO" "Detroit School tests completed successfully"
}
# London School: Mock external dependencies, focus on interactions
test_with_mocked_dependencies() {
log "INFO" "Running London School tests with mocked dependencies"
# Create mock functions that verify interactions
declare -a mock_calls=()
# Mock database operations
_execute_test_query() {
local query="$1"
mock_calls+=("database: $query")
if [[ "$query" =~ INSERT ]]; then
echo "1" # Return mock order ID
elif [[ "$query" =~ SELECT.*COUNT ]]; then
echo "1" # Return mock count
fi
}
# Mock payment processing
_process_test_payment() {
local amount="$1" payment_method="$2"
mock_calls+=("payment: $amount via $payment_method")
return 0 # Always succeed for interaction testing
}
# Mock notifications
_send_test_notification() {
local recipient="$1" subject="$2" message="$3"
mock_calls+=("notification: $recipient - $subject")
return 0
}
# Configure test environment
export DATABASE_TYPE="test"
export PAYMENT_GATEWAY="test"
export NOTIFICATION_SERVICE="test"
# Test order processing
if process_order 123 "100.00" "credit_card" "test@example.com"; then
log "INFO" "✓ Mocked order processing test passed"
else
log "ERROR" "✗ Mocked order processing test failed"
return 1
fi
# Verify expected interactions occurred
local expected_calls=4 # INSERT, UPDATE, payment, notification
if (( ${#mock_calls[@]} == expected_calls )); then
log "INFO" "✓ Interaction verification test passed (${#mock_calls[@]} calls)"
else
log "ERROR" "✗ Interaction verification test failed (expected $expected_calls, got ${#mock_calls[@]})"
return 1
fi
# Display interaction log
log "DEBUG" "Mock interactions:"
for call in "${mock_calls[@]}"; do
log "DEBUG" " - $call"
done
log "INFO" "London School tests completed successfully"
}
# Hybrid Approach: Mix real and mock implementations
test_hybrid_approach() {
log "INFO" "Running Hybrid tests with mixed implementations"
# Use real database (fast SQLite)
export DATABASE_TYPE="sqlite"
export SQLITE_DB="/tmp/test_orders_hybrid.db"
# Mock external payment service
export PAYMENT_GATEWAY="test"
export TEST_PAYMENT_SUCCESS_RATE="100"
# Use real notification logic but mock delivery
export NOTIFICATION_SERVICE="test"
# Initialize test database
database_execute "CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER, amount REAL, status TEXT);"
database_execute "DELETE FROM orders;"
# Test with real database persistence but mocked external services
if process_order 456 "200.00" "paypal" "hybrid@example.com"; then
log "INFO" "✓ Hybrid test passed"
else
log "ERROR" "✗ Hybrid test failed"
return 1
fi
# Verify with real database query
local saved_amount
saved_amount=$(database_execute "SELECT amount FROM orders WHERE customer_id = 456;")
expected_amount="216.00" # 200 + 8% tax
if [[ "$saved_amount" == "$expected_amount" ]]; then
log "INFO" "✓ Real database verification passed"
else
log "ERROR" "✗ Real database verification failed (expected $expected_amount, got $saved_amount)"
return 1
fi
log "INFO" "Hybrid tests completed successfully"
}
# Main execution function
main() {
log "INFO" "Starting Order Processing System"
log "INFO" "Environment: $ENVIRONMENT, PHP Version: $PHP_VERSION"
log "INFO" "Script directory: $SCRIPT_DIR"
# Parse command line arguments
local action="${1:-help}"
case "$action" in
"process-order")
shift
if (( $# != 4 )); then
log "ERROR" "Usage: $0 process-order <customer_id> <amount> <payment_method> <email>"
exit 1
fi
process_order "$@"
;;
"test-detroit")
test_with_real_implementations
;;
"test-london")
test_with_mocked_dependencies
;;
"test-hybrid")
test_hybrid_approach
;;
"test-all")
test_with_real_implementations &&
test_with_mocked_dependencies &&
test_hybrid_approach &&
log "INFO" "All tests passed successfully!"
;;
"help"|*)
cat <<EOF
Order Processing System - Dependency Inversion Demo
Usage: $0 <action> [arguments]
Actions:
process-order <customer_id> <amount> <payment_method> <email>
Process a single order with the given parameters
test-detroit
Run tests using real implementations (Detroit School)
test-london
Run tests using mocked dependencies (London School)
test-hybrid
Run tests using mixed real/mock approach (Pragmatic)
test-all
Run all test suites
help
Show this help message
Environment Variables:
ENVIRONMENT - development|production (default: development)
DATABASE_TYPE - mysql|sqlite|test (default: mysql)
PAYMENT_GATEWAY - stripe|paypal|test (default: stripe)
NOTIFICATION_SERVICE - email|slack|test (default: email)
LOG_LEVEL - debug|info|warning|error (default: info)
CUSTOMER_TYPE - standard|premium|vip (default: standard)
Examples:
# Process an order in development
ENVIRONMENT=development $0 process-order 123 100.00 credit_card user@example.com
# Run tests with verbose output
LOG_LEVEL=debug $0 test-all
# Use test implementations
DATABASE_TYPE=test PAYMENT_GATEWAY=test NOTIFICATION_SERVICE=test $0 process-order 456 50.00 paypal test@example.com
EOF
;;
esac
}
# Execute main function with all arguments
main "$@"
Shell Script Testing Patterns
Testing shell scripts requires creativity, but the same principles apply:
- Environment variable injection: Use environment variables as dependency injection mechanism
- Function composition: Break scripts into testable functions
- Mock external commands: Override external commands with functions for testing
- Real file operations: Use temporary directories for actual file system tests
PHP 8.4 Specific Features for Dependency Inversion
PHP 8.4 introduces several features that enhance dependency inversion implementation:
Property Hooks for Lazy Initialization
class LazyOrderProcessor
{
private ?OrderStorageInterface $storage = null;
public OrderStorageInterface $storage {
get {
return $this->storage ??= $this->createStorage();
}
set {
$this->storage = $value;
}
}
private function createStorage(): OrderStorageInterface
{
return match($this->environment) {
'testing' => new InMemoryOrderStorage(),
'production' => new MySqlOrderStorage($this->connection),
default => new SqliteOrderStorage()
};
}
}
Asymmetric Visibility for Immutable Dependencies
final class SecureOrderProcessor
{
// Public read, private write - prevents external modification
public private(set) PaymentGatewayInterface $paymentGateway;
public function __construct(PaymentGatewayInterface $paymentGateway)
{
$this->paymentGateway = $paymentGateway;
}
// Gateway cannot be modified after construction
// but can be read for testing and debugging
}
Lazy Objects for Performance
PHP 8.4's lazy objects feature allows for sophisticated dependency injection patterns:
$lazyDatabase = LazyObjectFactory::create(
MySqlDatabase::class,
function() {
return new MySqlDatabase(
$this->config['database']['host'],
$this->config['database']['name']
);
}
);
// Database connection only created when first accessed
$processor = new OrderProcessor($validator, $taxCalculator, $lazyDatabase);
Performance Considerations
Dependency inversion and testing strategies have performance implications that should be considered:
Runtime Performance
- Final classes: Enable better opcache optimizations in PHP 8.4
- Interface calls: Minimal overhead in modern PHP versions
- Lazy loading: Defer expensive object creation until needed
- Container caching: Cache dependency injection container configuration
Testing Performance
- Real objects: Often faster than mocks for simple operations
- In-memory implementations: Provide realistic testing without I/O overhead
- Mock setup overhead: Consider the cost of mock configuration
- Parallel testing: Real objects enable better test parallelization
Common Pitfalls and Solutions
Over-Engineering with Interfaces
Problem: Creating interfaces for every class, even simple value objects.
Solution: Only create interfaces when you need polymorphism or dependency inversion. Simple data classes don't need interfaces.
Mock-Heavy Tests
Problem: Mocking everything leads to brittle tests that break on refactoring.
Solution: Use the pragmatic approach—mock external dependencies, use real objects for internal logic.
Inheritance Instead of Composition
Problem: Using abstract base classes instead of dependency injection.
Solution: Favor final classes with injected dependencies over inheritance hierarchies.
Configuration Explosion
Problem: Too many configuration options make the system hard to understand.
Solution: Provide sensible defaults and environment-based configurations.
Best Practices and Guidelines
Design Guidelines
- Prefer final classes: Use final classes with dependency injection over inheritance
- Design by contract: Create explicit interfaces for dependencies
- Single responsibility: Each class should have one reason to change
- Immutable dependencies: Don't allow dependencies to change after construction
- Environment-based configuration: Use environment variables for different implementations
Testing Guidelines
- Start with real objects: Use real implementations unless there's a compelling reason to mock
- Mock external boundaries: Always mock databases, HTTP services, file systems
- Test behavior, not implementation: Focus on what the code does, not how
- Use hybrid approaches: Combine real and mock objects in the same test
- Maintain test speed: Fast tests encourage frequent execution
PHP 8.4 Specific Guidelines
- Leverage property hooks: Use for lazy initialization and validation
- Use asymmetric visibility: Prevent unwanted modifications while maintaining transparency
- Adopt lazy objects: For expensive dependencies that may not be used
- Final by default: Make classes final unless extension is explicitly needed
- Type everything: Use PHP 8.4's enhanced type system for better static analysis
Tools and Resources
Essential PHP Tools
- PHPUnit 10+: Modern testing framework with improved mocking capabilities
- PHPStan: Static analysis for catching dependency injection issues
- PHP-DI: Mature dependency injection container
- Symfony DI: Powerful dependency injection component
- Psalm: Advanced static analysis with template support
Testing Resources
- Mocks Aren't Stubs: Martin Fowler's classic article on testing approaches
- Growing Object-Oriented Software, Guided by Tests: The definitive book on London School TDD
- The Little Mocker: Uncle Bob's perspective on when to mock
- PHP: The Right Way - Testing: Community guidelines for PHP testing
PHP 8.4 Documentation
- PHP 8.4 Release Notes: Official feature documentation
- PHP.Watch 8.4: Comprehensive guide to new features
- What's New in PHP 8.4: Developer-focused feature overview
Conclusion
The combination of PHP 8.4's modern features, dependency inversion principles, and pragmatic testing approaches creates a powerful foundation for building maintainable, testable applications. The key insights from this exploration are:
- Final classes encourage composition: By preventing inheritance, final classes naturally lead to better dependency inversion patterns
- Testing is contextual: The Detroit vs London school debate misses the point—use the right approach for each situation
- Real objects are undervalued: Many dependencies can and should be tested with real implementations for better confidence
- Modern PHP enables elegant patterns: PHP 8.4's features make dependency inversion more natural and performant
- Pragmatism over purity: Combine approaches based on practical concerns rather than ideological adherence
As the PHP ecosystem continues to evolve, these patterns will become increasingly important for building scalable, maintainable applications. The investment in understanding and applying these concepts pays dividends in code quality, testing confidence, and long-term maintainability.
The future of PHP development lies not in choosing between different approaches, but in understanding when and how to apply each technique for maximum benefit. Whether you're building microservices, monoliths, or anything in between, these principles provide a solid foundation for clean, testable, and maintainable code.