Oscar Coleto

SOLID en Laravel (3/5): Principio de Sustitución de Liskov con Stripe

Introducción

Bienvenido al tercer artículo de SOLID en Laravel. Hoy exploraremos el Principio de Sustitución de Liskov (LSP), que establece:

“Los objetos de una clase derivada deben poder sustituir objetos de la clase base sin alterar el correcto funcionamiento del programa”

En términos prácticos: Si tu código espera una interfaz, cualquier implementación debe comportarse de forma predecible.

El Problema: Implementaciones Inconsistentes

Siguiendo con nuestro ejemplo de gateways de pago, veamos cómo se viola comúnmente el LSP:

<?php

namespace App\Contracts;

interface PaymentGatewayInterface
{
    /**
     * Procesa un pago
     * @return PaymentResult
     */
    public function charge(float $amount, array $data): PaymentResult;
}

Implementación 1: Stripe (Comportamiento Esperado)

<?php

namespace App\Services\PaymentGateways;

class StripeGateway implements PaymentGatewayInterface
{
    public function charge(float $amount, array $data): PaymentResult
    {
        if ($amount <= 0) {
            throw new \InvalidArgumentException('Amount must be positive');
        }

        // Procesa el pago
        $paymentIntent = PaymentIntent::create([
            'amount' => $amount * 100,
            'currency' => 'usd',
            'payment_method' => $data['payment_method'],
        ]);

        return new PaymentResult(
            success: $paymentIntent->status === 'succeeded',
            transactionId: $paymentIntent->id,
        );
    }
}

Implementación 2: PayPal (Viola LSP)

<?php

namespace App\Services\PaymentGateways;

class PayPalGateway implements PaymentGatewayInterface
{
    public function charge(float $amount, array $data): PaymentResult
    {
        // VIOLACIÓN: Cambia el comportamiento esperado

        // 1. No valida montos negativos
        if ($amount < 50) {
            // Lanza excepción por monto mínimo (nuevo comportamiento)
            throw new \Exception('PayPal requires minimum $50');
        }

        // 2. ❌ Requiere datos diferentes en el array
        if (!isset($data['return_url']) || !isset($data['cancel_url'])) {
            throw new \Exception('PayPal requires return_url and cancel_url');
        }

        // 3. ❌ Retorna null en lugar de PaymentResult cuando hay error
        try {
            $payment = $this->createPayPalPayment($amount, $data);
        } catch (\Exception $e) {
            return null; // Violación del contrato
        }

        // 4. ❌ El transactionId puede ser null
        return new PaymentResult(
            success: true,
            transactionId: $payment->getId(), // Puede ser null
        );
    }
}

Implementación 3: CryptoGateway (Viola LSP Completamente)

<?php

namespace App\Services\PaymentGateways;

class CryptoGateway implements PaymentGatewayInterface
{
    // VIOLACIÓN CRÍTICA: Cambia completamente el comportamiento
    public function charge(float $amount, array $data): PaymentResult
    {
        // No procesa el pago inmediatamente
        // En su lugar, crea una "factura" que el usuario paga después

        $invoice = $this->createCryptoInvoice($amount, $data);

        // Retorna "success" aunque NO se haya cobrado
        return new PaymentResult(
            success: true, // ¡MENTIRA! No se ha cobrado aún
            transactionId: $invoice->id,
        );
    }
}

¿Por qué esto viola LSP?

Cuando tu código usa estas implementaciones, no son intercambiables:

class SubscriptionService
{
    public function subscribe(User $user, PaymentGatewayInterface $gateway)
    {
        // Este código ASUME comportamiento consistente
        $result = $gateway->charge(29.99, [
            'payment_method' => $user->payment_method,
        ]);

        if ($result->success) {
            // Con PayPal: Falla (requiere return_url)
            // Con Crypto: Usuario NO está suscrito (pago pendiente)
            // Con Stripe: Funciona correctamente

            $user->activatePremium();
            return $result->transactionId;
            // Con PayPal: Puede ser null
        }
    }
}

Consecuencias:

  • ❌ Necesitas if ($gateway instanceof PayPalGateway) en todos lados
  • ❌ Bugs difíciles de detectar (dependen del gateway usado)
  • ❌ Testing poco confiable
  • ❌ No puedes intercambiar implementaciones libremente

La Solución: Cumplir el Contrato

1. Definir un Contrato Sólido

<?php

namespace App\Contracts;

use App\ValueObjects\PaymentResult;
use App\Exceptions\PaymentException;

interface PaymentGatewayInterface
{
    /**
     * Procesa un cargo inmediato.
     *
     * Precondiciones:
     * - $amount debe ser > 0
     * - $data debe contener las claves requeridas por getRequiredFields()
     *
     * Postcondiciones:
     * - SIEMPRE retorna PaymentResult (nunca null)
     * - Si success = true, el pago FUE procesado exitosamente
     * - Si success = false, el pago NO fue procesado
     * - transactionId nunca es null cuando success = true
     *
     * @throws PaymentException En caso de error de configuración o validación
     */
    public function charge(float $amount, array $data): PaymentResult;

    /**
     * Retorna los campos requeridos en el array $data
     *
     * @return array Lista de claves requeridas
     */
    public function getRequiredFields(): array;

    /**
     * Retorna el monto mínimo permitido por este gateway
     *
     * @return float Monto mínimo en USD
     */
    public function getMinimumAmount(): float;

    /**
     * Indica si este gateway procesa pagos de forma síncrona
     *
     * @return bool true = pago inmediato, false = pago asíncrono
     */
    public function isSynchronous(): bool;
}

2. Implementación Correcta de Stripe

<?php

namespace App\Services\PaymentGateways;

use App\Contracts\PaymentGatewayInterface;
use App\ValueObjects\PaymentResult;
use App\Exceptions\PaymentException;

class StripeGateway implements PaymentGatewayInterface
{
    public function charge(float $amount, array $data): PaymentResult
    {
        // Validar precondiciones
        $this->validateAmount($amount);
        $this->validateRequiredFields($data);

        try {
            $paymentIntent = PaymentIntent::create([
                'amount' => $amount * 100,
                'currency' => 'usd',
                'payment_method' => $data['payment_method'],
                'confirm' => true,
            ]);

            // Garantizar postcondiciones
            return PaymentResult::success(
                transactionId: $paymentIntent->id, // Nunca null
                status: $paymentIntent->status,
            );
        } catch (\Stripe\Exception\CardException $e) {
            // Retorna PaymentResult, no null
            return PaymentResult::failed($e->getMessage());
        } catch (\Exception $e) {
            throw new PaymentException(
                "Stripe error: {$e->getMessage()}",
                previous: $e
            );
        }
    }

    public function getRequiredFields(): array
    {
        return ['payment_method'];
    }

    public function getMinimumAmount(): float
    {
        return 0.50; // Mínimo de Stripe
    }

    public function isSynchronous(): bool
    {
        return true; // Stripe procesa inmediatamente
    }

    private function validateAmount(float $amount): void
    {
        if ($amount <= 0) {
            throw new PaymentException('Amount must be greater than zero');
        }

        if ($amount < $this->getMinimumAmount()) {
            throw new PaymentException(
                "Amount must be at least \${$this->getMinimumAmount()}"
            );
        }
    }

    private function validateRequiredFields(array $data): void
    {
        foreach ($this->getRequiredFields() as $field) {
            if (!isset($data[$field])) {
                throw new PaymentException("Missing required field: {$field}");
            }
        }
    }
}

3. Implementación Correcta de PayPal

<?php

namespace App\Services\PaymentGateways;

use App\Contracts\PaymentGatewayInterface;
use App\ValueObjects\PaymentResult;
use App\Exceptions\PaymentException;

class PayPalGateway implements PaymentGatewayInterface
{
    public function charge(float $amount, array $data): PaymentResult
    {
        // Mismas validaciones que Stripe
        $this->validateAmount($amount);
        $this->validateRequiredFields($data);

        try {
            $payment = new \PayPal\Api\Payment();
            $payment->setIntent('sale')
                ->setPayer($this->createPayer($data))
                ->setTransactions([$this->createTransaction($amount)])
                ->setRedirectUrls($this->createRedirectUrls($data));

            $payment->create($this->apiContext);

            // Garantiza transactionId nunca null
            $transactionId = $payment->getId() ??
                throw new PaymentException('PayPal did not return transaction ID');

            return PaymentResult::success(
                transactionId: $transactionId,
                status: $payment->getState(),
                metadata: [
                    'approval_url' => $this->getApprovalUrl($payment),
                ]
            );
        } catch (\PayPal\Exception\PayPalConnectionException $e) {
            return PaymentResult::failed($e->getMessage());
        }
    }

    public function getRequiredFields(): array
    {
        // PayPal documenta sus campos requeridos
        return ['payment_method', 'return_url', 'cancel_url'];
    }

    public function getMinimumAmount(): float
    {
        return 1.00; // PayPal mínimo real
    }

    public function isSynchronous(): bool
    {
        return true; // El cargo se crea inmediatamente
    }

    // ... resto de métodos
}

4. Adaptador para Gateways Asíncronos

Para gateways como Crypto que NO procesan inmediatamente, usamos un adaptador:

<?php

namespace App\Services\PaymentGateways;

use App\Contracts\PaymentGatewayInterface;

/**
 * Adaptador para gateways asíncronos
 * Convierte operaciones asíncronas en síncronas mediante polling
 */
class AsyncGatewayAdapter implements PaymentGatewayInterface
{
    public function __construct(
        private AsynchronousPaymentGateway $asyncGateway,
        private int $maxWaitSeconds = 300
    ) {}

    public function charge(float $amount, array $data): PaymentResult
    {
        // Crear invoice
        $invoice = $this->asyncGateway->createInvoice($amount, $data);

        // Esperar confirmación o timeout
        $startTime = time();

        while (time() - $startTime < $this->maxWaitSeconds) {
            $status = $this->asyncGateway->checkInvoiceStatus($invoice->id);

            if ($status === 'paid') {
                return PaymentResult::success(
                    transactionId: $invoice->id,
                    status: 'completed'
                );
            }

            if ($status === 'expired') {
                return PaymentResult::failed('Invoice expired');
            }

            sleep(5); // Poll cada 5 segundos
        }

        // Timeout
        return PaymentResult::failed('Payment timeout');
    }

    public function isSynchronous(): bool
    {
        return false; // Documenta su naturaleza asíncrona
    }

    // ... resto de métodos
}

5. Uso con Validación de Precondiciones

<?php

namespace App\Services;

use App\Contracts\PaymentGatewayInterface;
use App\Exceptions\PaymentException;

class PaymentProcessor
{
    public function processPayment(
        PaymentGatewayInterface $gateway,
        float $amount,
        array $data
    ): PaymentResult {
        // Validar que se cumplen las precondiciones
        if ($amount < $gateway->getMinimumAmount()) {
            throw new PaymentException(
                "Amount \${$amount} is below minimum \${$gateway->getMinimumAmount()}"
            );
        }

        $requiredFields = $gateway->getRequiredFields();
        $missingFields = array_diff($requiredFields, array_keys($data));

        if (!empty($missingFields)) {
            throw new PaymentException(
                'Missing required fields: ' . implode(', ', $missingFields)
            );
        }

        // Ahora SABEMOS que cualquier gateway funcionará igual
        $result = $gateway->charge($amount, $data);

        // Podemos confiar en las postcondiciones
        if ($result->success) {
            // transactionId NUNCA será null aquí
            $this->logTransaction($result->transactionId, $amount);
        }

        return $result;
    }

    public function canProcessSynchronously(PaymentGatewayInterface $gateway): bool
    {
        return $gateway->isSynchronous();
    }
}

Comparación

Antes (Violando LSP)

// Código lleno de verificaciones de tipo
public function subscribe(User $user, PaymentGatewayInterface $gateway)
{
    if ($gateway instanceof PayPalGateway) {
        $data['return_url'] = route('payment.return');
        $data['cancel_url'] = route('payment.cancel');
    }

    if ($gateway instanceof CryptoGateway) {
        // No activar premium inmediatamente
        $result = $gateway->charge($amount, $data);
        $this->createPendingSubscription($user, $result);
        return;
    }

    $result = $gateway->charge($amount, $data);

    if ($result === null) {
        // Solo pasa con PayPal mal implementado
        throw new \Exception('Payment failed');
    }

    if ($result->success && $result->transactionId !== null) {
        $user->activatePremium();
    }
}

Después (Cumpliendo LSP)

// Código limpio, confía en el contrato
public function subscribe(User $user, PaymentGatewayInterface $gateway)
{
    // Preparar datos según el gateway
    $data = $this->preparePaymentData($user, $gateway);

    // Procesar - CUALQUIER gateway funcionará igual
    $result = $gateway->charge($amount, $data);

    // Confiar en las postcondiciones
    if ($result->success) {
        // transactionId SIEMPRE existe
        $user->activatePremium($result->transactionId);
    }

    return $result;
}

private function preparePaymentData(User $user, PaymentGatewayInterface $gateway): array
{
    $data = ['payment_method' => $user->payment_method];

    // El gateway DICE qué necesita
    foreach ($gateway->getRequiredFields() as $field) {
        if ($field === 'return_url') {
            $data[$field] = route('payment.return');
        }
        if ($field === 'cancel_url') {
            $data[$field] = route('payment.cancel');
        }
    }

    return $data;
}

Testing con LSP

<?php

namespace Tests\Unit\PaymentGateways;

use Tests\TestCase;

/**
 * Test de Sustitución de Liskov
 * TODOS los gateways deben pasar TODOS estos tests
 */
abstract class PaymentGatewayContractTest extends TestCase
{
    abstract protected function createGateway(): PaymentGatewayInterface;

    public function test_charge_with_valid_data_returns_payment_result()
    {
        $gateway = $this->createGateway();

        $result = $gateway->charge(100, [
            'payment_method' => 'test_pm_123',
        ]);

        $this->assertInstanceOf(PaymentResult::class, $result);
    }

    public function test_successful_charge_has_transaction_id()
    {
        $gateway = $this->createGateway();

        $result = $gateway->charge(100, $this->getValidPaymentData());

        if ($result->success) {
            $this->assertNotNull($result->transactionId);
            $this->assertNotEmpty($result->transactionId);
        }
    }

    public function test_charge_throws_exception_for_negative_amount()
    {
        $this->expectException(PaymentException::class);

        $gateway = $this->createGateway();
        $gateway->charge(-50, $this->getValidPaymentData());
    }

    public function test_charge_throws_exception_for_missing_required_fields()
    {
        $this->expectException(PaymentException::class);

        $gateway = $this->createGateway();
        $gateway->charge(100, []); // Sin campos requeridos
    }
}

// Cada gateway extiende este test
class StripeGatewayTest extends PaymentGatewayContractTest
{
    protected function createGateway(): PaymentGatewayInterface
    {
        return new StripeGateway();
    }
}

class PayPalGatewayTest extends PaymentGatewayContractTest
{
    protected function createGateway(): PaymentGatewayInterface
    {
        return new PayPalGateway();
    }
}

Reglas para Cumplir LSP

  1. No fortalecer precondiciones: Las subclases no pueden requerir más que la clase base
  2. No debilitar postcondiciones: Las subclases deben garantizar al menos lo mismo
  3. Preservar invariantes: Las reglas de la clase base deben mantenerse
  4. No lanzar excepciones nuevas: Solo las documentadas en la clase base

Conclusión

El Principio de Sustitución de Liskov garantiza que:

  • ✅ Las implementaciones sean verdaderamente intercambiables
  • ✅ No necesites instanceof en tu código
  • ✅ Los tests sean confiables y reutilizables
  • ✅ El polimorfismo funcione correctamente

Regla de oro: Si tu código pregunta “¿qué tipo de implementación es esto?”, estás violando LSP.

En el próximo artículo exploraremos el Principio de Segregación de Interfaces, donde veremos cómo crear interfaces pequeñas y enfocadas.


Artículo anterior: SOLID en Laravel (2/5): Principio Open/Closed Próximo artículo: SOLID en Laravel (4/5): Principio de Segregación de Interfaces