Oscar Coleto

SOLID en Laravel (5/5): Principio de Inversión de Dependencias con Stripe

Introducción

Bienvenido al último artículo de nuestra serie SOLID en Laravel. Hoy exploraremos el Principio de Inversión de Dependencias (DIP), que establece:

“Los módulos de alto nivel no deben depender de módulos de bajo nivel. Ambos deben depender de abstracciones.”

“Las abstracciones no deben depender de detalles. Los detalles deben depender de abstracciones.”

En términos simples: Depende de interfaces, no de clases concretas.

El Problema: Acoplamiento Directo

Veamos cómo muchos desarrolladores implementan la lógica de suscripciones acoplada directamente a Stripe:

<?php

namespace App\Services;

use Stripe\Stripe;
use Stripe\Customer;
use Stripe\Subscription;
use App\Models\User;

/**
 * ❌ Servicio de alto nivel acoplado a implementación concreta (Stripe)
 */
class SubscriptionService
{
    public function subscribe(User $user, string $planId, string $paymentMethod): bool
    {
        // Dependencia DIRECTA de la SDK de Stripe
        Stripe::setApiKey(config('services.stripe.secret'));

        try {
            // Código fuertemente acoplado a Stripe
            if (!$user->stripe_customer_id) {
                $customer = Customer::create([
                    'email' => $user->email,
                    'name' => $user->name,
                    'payment_method' => $paymentMethod,
                ]);
                $user->stripe_customer_id = $customer->id;
                $user->save();
            }

            $subscription = Subscription::create([
                'customer' => $user->stripe_customer_id,
                'items' => [['price' => $planId]],
            ]);

            $user->subscriptions()->create([
                'stripe_subscription_id' => $subscription->id,
                'status' => $subscription->status,
            ]);

            return true;
        } catch (\Stripe\Exception\ApiException $e) {
            // Manejo de excepciones específico de Stripe
            \Log::error('Stripe error: ' . $e->getMessage());
            return false;
        }
    }

    public function cancel(User $user): bool
    {
        // Más acoplamiento directo
        Stripe::setApiKey(config('services.stripe.secret'));

        $subscription = Subscription::retrieve($user->stripe_subscription_id);
        $subscription->cancel();

        return true;
    }
}

Más Ejemplos de Acoplamiento

<?php

namespace App\Http\Controllers;

use App\Services\SubscriptionService;
use Stripe\Webhook;

class WebhookController extends Controller
{
    public function handleStripe(Request $request)
    {
        // Controlador acoplado a Stripe
        $signature = $request->header('Stripe-Signature');

        try {
            // Dependencia directa de SDK
            $event = Webhook::constructEvent(
                $request->getContent(),
                $signature,
                config('services.stripe.webhook_secret')
            );

            // Lógica de negocio mezclada con detalles de Stripe
            if ($event->type === 'customer.subscription.deleted') {
                $subscription = $event->data->object;
                $user = User::where('stripe_customer_id', $subscription->customer)->first();
                $user->subscription->update(['status' => 'cancelled']);
            }

            return response()->json(['success' => true]);
        } catch (\Stripe\Exception\SignatureVerificationException $e) {
            // Excepciones específicas de Stripe
            return response()->json(['error' => 'Invalid signature'], 400);
        }
    }
}

¿Por qué esto viola DIP?

Problemas

  1. ❌ Imposible de testear: No puedes testear sin llamar a Stripe real
  2. ❌ Imposible cambiar de proveedor: Cambiar a PayPal requiere reescribir todo
  3. ❌ Lógica de negocio acoplada a infraestructura: Mezcla reglas de negocio con detalles técnicos
  4. ❌ Código frágil: Un cambio en Stripe rompe tu aplicación
  5. ❌ Violación de DIP: Módulo de alto nivel (SubscriptionService) depende de bajo nivel (Stripe SDK)

La Solución: Inversión de Dependencias

Arquitectura con DIP

┌─────────────────────────────────────────┐
│   Capa de Aplicación (Alto Nivel)       │
│   SubscriptionService, Controllers       │
│              ↓ depende de ↓             │
│         (Interfaces/Contratos)           │
│   PaymentGatewayInterface                │
│   WebhookHandlerInterface                │
│              ↑ implementan ↑            │
│   Capa de Infraestructura (Bajo Nivel)  │
│   StripeGateway, PayPalGateway          │
└─────────────────────────────────────────┘

1. Definir Abstracciones (Contratos)

<?php

namespace App\Contracts;

use App\ValueObjects\{PaymentResult, SubscriptionResult, Customer};

/**
 * ✅ Abstracción de alto nivel
 * Define QUÉ necesitamos, no CÓMO se implementa
 */
interface PaymentGatewayInterface
{
    public function createCustomer(string $email, string $name): Customer;

    public function createSubscription(
        string $customerId,
        string $planId,
        string $paymentMethodId
    ): SubscriptionResult;

    public function cancelSubscription(string $subscriptionId): bool;
}

interface WebhookHandlerInterface
{
    public function verify(string $payload, string $signature): bool;

    public function handle(string $payload): WebhookEvent;
}

interface NotificationServiceInterface
{
    public function sendSubscriptionConfirmation(User $user): void;

    public function sendCancellationNotice(User $user): void;
}

2. Implementar Detalles (Infraestructura)

<?php

namespace App\Infrastructure\Payment;

use App\Contracts\PaymentGatewayInterface;
use Stripe\Stripe;
use Stripe\Customer as StripeCustomer;
use Stripe\Subscription as StripeSubscription;

/**
 * ✅ Implementación concreta (bajo nivel)
 * Depende de la abstracción, no al revés
 */
class StripePaymentGateway implements PaymentGatewayInterface
{
    public function __construct()
    {
        // Detalles de Stripe encapsulados aquí
        Stripe::setApiKey(config('services.stripe.secret'));
    }

    public function createCustomer(string $email, string $name): Customer
    {
        try {
            $stripeCustomer = StripeCustomer::create([
                'email' => $email,
                'name' => $name,
            ]);

            // Convertir objeto de Stripe a nuestro dominio
            return new Customer(
                id: $stripeCustomer->id,
                email: $stripeCustomer->email,
                name: $stripeCustomer->name,
            );
        } catch (\Stripe\Exception\ApiException $e) {
            // Convertir excepciones de Stripe a excepciones de dominio
            throw new PaymentGatewayException(
                "Failed to create customer: {$e->getMessage()}",
                previous: $e
            );
        }
    }

    public function createSubscription(
        string $customerId,
        string $planId,
        string $paymentMethodId
    ): SubscriptionResult {
        try {
            $subscription = StripeSubscription::create([
                'customer' => $customerId,
                'items' => [['price' => $planId]],
                'default_payment_method' => $paymentMethodId,
            ]);

            // Mapear a objetos de dominio
            return new SubscriptionResult(
                id: $subscription->id,
                status: $subscription->status,
                currentPeriodEnd: $subscription->current_period_end,
            );
        } catch (\Stripe\Exception\ApiException $e) {
            throw new PaymentGatewayException(
                "Failed to create subscription: {$e->getMessage()}",
                previous: $e
            );
        }
    }

    public function cancelSubscription(string $subscriptionId): bool
    {
        try {
            $subscription = StripeSubscription::retrieve($subscriptionId);
            $subscription->cancel();
            return true;
        } catch (\Stripe\Exception\ApiException $e) {
            throw new PaymentGatewayException(
                "Failed to cancel subscription: {$e->getMessage()}",
                previous: $e
            );
        }
    }
}

3. Implementación Alternativa (PayPal)

<?php

namespace App\Infrastructure\Payment;

use App\Contracts\PaymentGatewayInterface;

/**
 * ✅ Otra implementación de la misma abstracción
 * Lógica de negocio NO cambia
 */
class PayPalPaymentGateway implements PaymentGatewayInterface
{
    public function __construct(
        private PayPalApiContext $apiContext
    ) {}

    public function createCustomer(string $email, string $name): Customer
    {
        // Implementación específica de PayPal
        // Pero retorna el MISMO tipo (Customer)
        $paypalCustomer = $this->apiContext->createCustomer([
            'email' => $email,
            'name' => $name,
        ]);

        return new Customer(
            id: $paypalCustomer->id,
            email: $email,
            name: $name,
        );
    }

    public function createSubscription(
        string $customerId,
        string $planId,
        string $paymentMethodId
    ): SubscriptionResult {
        // Lógica de PayPal completamente diferente
        // Pero la INTERFAZ es la misma
    }

    // ... resto de métodos
}

4. Servicio de Aplicación (Alto Nivel)

<?php

namespace App\Services;

use App\Contracts\{PaymentGatewayInterface, NotificationServiceInterface};
use App\Models\User;
use App\Exceptions\SubscriptionException;

/**
 * ✅ Servicio de alto nivel
 * Depende solo de ABSTRACCIONES
 */
class SubscriptionService
{
    public function __construct(
        private PaymentGatewayInterface $paymentGateway,
        private NotificationServiceInterface $notifier
    ) {
        // Recibe INTERFACES, no implementaciones concretas
        // No sabe si es Stripe, PayPal o cualquier otro
    }

    public function subscribe(User $user, string $planId, string $paymentMethodId): void
    {
        // Lógica de negocio pura, sin detalles técnicos

        // 1. Crear cliente si no existe
        if (!$user->payment_customer_id) {
            $customer = $this->paymentGateway->createCustomer(
                $user->email,
                $user->name
            );

            $user->update(['payment_customer_id' => $customer->id]);
        }

        // 2. Crear suscripción
        $subscription = $this->paymentGateway->createSubscription(
            customerId: $user->payment_customer_id,
            planId: $planId,
            paymentMethodId: $paymentMethodId
        );

        // 3. Guardar en base de datos
        $user->subscriptions()->create([
            'external_id' => $subscription->id,
            'status' => $subscription->status,
            'current_period_end' => $subscription->currentPeriodEnd,
        ]);

        // 4. Notificar usuario
        $this->notifier->sendSubscriptionConfirmation($user);
    }

    public function cancel(User $user): void
    {
        $subscription = $user->activeSubscription;

        if (!$subscription) {
            throw new SubscriptionException('No active subscription found');
        }

        // Lógica de negocio independiente del gateway
        $this->paymentGateway->cancelSubscription($subscription->external_id);

        $subscription->update(['status' => 'cancelled']);

        $this->notifier->sendCancellationNotice($user);
    }
}

5. Configuración de Dependencias (Service Provider)

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Contracts\{PaymentGatewayInterface, NotificationServiceInterface};
use App\Infrastructure\Payment\StripePaymentGateway;
use App\Infrastructure\Notifications\EmailNotificationService;

class PaymentServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Configurar qué implementación usar
        $this->app->bind(
            PaymentGatewayInterface::class,
            function ($app) {
                // Cambiar implementación desde config
                $gateway = config('payments.default_gateway');

                return match($gateway) {
                    'stripe' => $app->make(StripePaymentGateway::class),
                    'paypal' => $app->make(PayPalPaymentGateway::class),
                    default => throw new \Exception("Unsupported gateway: {$gateway}")
                };
            }
        );

        $this->app->bind(
            NotificationServiceInterface::class,
            EmailNotificationService::class
        );
    }
}

6. Testing con Mocks

<?php

namespace Tests\Unit\Services;

use Tests\TestCase;
use App\Services\SubscriptionService;
use App\Contracts\{PaymentGatewayInterface, NotificationServiceInterface};
use App\Models\User;
use Mockery;

class SubscriptionServiceTest extends TestCase
{
    public function test_subscribe_creates_customer_and_subscription()
    {
        // Mockear la INTERFAZ, no la implementación
        $gateway = Mockery::mock(PaymentGatewayInterface::class);
        $notifier = Mockery::mock(NotificationServiceInterface::class);

        $gateway->shouldReceive('createCustomer')
            ->once()
            ->with('[email protected]', 'Test User')
            ->andReturn(new Customer('cus_123', '[email protected]', 'Test User'));

        $gateway->shouldReceive('createSubscription')
            ->once()
            ->andReturn(new SubscriptionResult('sub_123', 'active', now()->addMonth()));

        $notifier->shouldReceive('sendSubscriptionConfirmation')
            ->once();

        // Test sin tocar Stripe ni PayPal
        $service = new SubscriptionService($gateway, $notifier);
        $user = User::factory()->create();

        $service->subscribe($user, 'plan_123', 'pm_123');

        $this->assertDatabaseHas('subscriptions', [
            'user_id' => $user->id,
            'external_id' => 'sub_123',
        ]);
    }
}

7. Uso en Controladores

<?php

namespace App\Http\Controllers;

use App\Services\SubscriptionService;
use Illuminate\Http\Request;

class SubscriptionController extends Controller
{
    public function __construct(
        private SubscriptionService $subscriptionService
        // Recibe servicio de alto nivel
        // No sabe nada de Stripe/PayPal
    ) {}

    public function store(Request $request)
    {
        $validated = $request->validate([
            'plan_id' => 'required|string',
            'payment_method' => 'required|string',
        ]);

        try {
            // Código limpio y enfocado
            $this->subscriptionService->subscribe(
                auth()->user(),
                $validated['plan_id'],
                $validated['payment_method']
            );

            return response()->json(['success' => true]);
        } catch (SubscriptionException $e) {
            return response()->json(['error' => $e->getMessage()], 400);
        }
    }
}

Comparación

Antes (Violando DIP)

// Alto nivel depende de bajo nivel
class SubscriptionService
{
    public function subscribe(...)
    {
        Stripe::setApiKey(...);              // Stripe concreto
        $customer = Customer::create(...);    // Clase de Stripe
        $subscription = Subscription::create(...); // Clase de Stripe
    }
}

// Imposible de testear
public function test_subscribe()
{
    // No puedes mockear Stripe sin paquetes adicionales
    $service = new SubscriptionService();
    $service->subscribe(...); // Llama a Stripe REAL
}

// Cambiar a PayPal = reescribir TODO

Después (Aplicando DIP)

// Alto nivel depende de abstracción
class SubscriptionService
{
    public function __construct(
        private PaymentGatewayInterface $gateway // Interfaz
    ) {}

    public function subscribe(...)
    {
        $customer = $this->gateway->createCustomer(...);
        $subscription = $this->gateway->createSubscription(...);
    }
}

// Testing trivial
public function test_subscribe()
{
    $mock = Mockery::mock(PaymentGatewayInterface::class);
    $service = new SubscriptionService($mock);
    // ...
}

// Cambiar a PayPal = cambiar 1 línea en config

Beneficios Reales

1. Cambiar de Proveedor

// En config/payments.php
'default_gateway' => env('PAYMENT_GATEWAY', 'stripe'),

// En .env
PAYMENT_GATEWAY=paypal  // Cambiado en 1 segundo

2. Testing con Implementación Fake

<?php

namespace App\Testing\Fakes;

class FakePaymentGateway implements PaymentGatewayInterface
{
    public array $createdCustomers = [];
    public array $createdSubscriptions = [];

    public function createCustomer(string $email, string $name): Customer
    {
        $customer = new Customer("fake_{$email}", $email, $name);
        $this->createdCustomers[] = $customer;
        return $customer;
    }

    public function assertCustomerCreated(string $email): void
    {
        $found = collect($this->createdCustomers)
            ->contains(fn($c) => $c->email === $email);

        PHPUnit::assertTrue($found, "Customer {$email} was not created");
    }
}

3. Múltiples Gateways Simultáneos

class MultiGatewayService implements PaymentGatewayInterface
{
    public function __construct(
        private PaymentGatewayInterface $primary,
        private PaymentGatewayInterface $fallback
    ) {}

    public function createSubscription(...): SubscriptionResult
    {
        try {
            return $this->primary->createSubscription(...);
        } catch (PaymentGatewayException $e) {
            Log::warning('Primary gateway failed, using fallback');
            return $this->fallback->createSubscription(...);
        }
    }
}

4. Logging Transparente

class LoggingPaymentGateway implements PaymentGatewayInterface
{
    public function __construct(
        private PaymentGatewayInterface $gateway,
        private LoggerInterface $logger
    ) {}

    public function createSubscription(...): SubscriptionResult
    {
        $this->logger->info('Creating subscription', [...]);

        $result = $this->gateway->createSubscription(...);

        $this->logger->info('Subscription created', ['id' => $result->id]);

        return $result;
    }
}

Casos de Uso Avanzados

A/B Testing de Gateways

class ABTestPaymentGateway implements PaymentGatewayInterface
{
    public function __construct(
        private PaymentGatewayInterface $gatewayA,
        private PaymentGatewayInterface $gatewayB,
        private float $ratioA = 0.5
    ) {}

    public function createSubscription(...): SubscriptionResult
    {
        $gateway = (rand(0, 100) / 100) < $this->ratioA
            ? $this->gatewayA
            : $this->gatewayB;

        return $gateway->createSubscription(...);
    }
}

Rate Limiting

class RateLimitedGateway implements PaymentGatewayInterface
{
    public function __construct(
        private PaymentGatewayInterface $gateway,
        private RateLimiter $limiter
    ) {}

    public function createSubscription(...): SubscriptionResult
    {
        if (!$this->limiter->attempt('payment', 10, 60)) {
            throw new RateLimitException('Too many payment attempts');
        }

        return $this->gateway->createSubscription(...);
    }
}

Conclusión

El Principio de Inversión de Dependencias es la culminación de SOLID:

  • Código testeable: Mock interfaces, no implementaciones
  • Código flexible: Cambiar implementaciones sin tocar lógica
  • Código mantenible: Lógica de negocio separada de infraestructura
  • Código escalable: Agregar funcionalidad sin modificar existente

Los 5 Principios SOLID Trabajando Juntos

  1. SRP: Cada clase tiene una responsabilidad
  2. OCP: Extiende sin modificar
  3. LSP: Implementaciones son intercambiables
  4. ISP: Interfaces pequeñas y específicas
  5. DIP: Depende de abstracciones

Resultado: Código profesional, mantenible y escalable.

Recursos Finales


Artículo anterior: SOLID en Laravel (4/5): Principio de Segregación de Interfaces


¡Gracias por seguir esta serie completa sobre SOLID en Laravel!

Serie Completa

  1. Principio de Responsabilidad Única (SRP)
  2. Principio Open/Closed (OCP)
  3. Principio de Sustitución de Liskov (LSP)
  4. Principio de Segregación de Interfaces (ISP)
  5. Principio de Inversión de Dependencias (DIP)