Oscar Coleto

SOLID en Laravel (1/5): Principio de Responsabilidad Única con Stripe

Introducción

Bienvenido a la primera parte de nuestra serie sobre Principios SOLID en Laravel. En esta serie de 5 artículos, exploraremos cada principio SOLID usando un caso de uso real: la integración de pagos con Stripe.

En este primer artículo, nos enfocaremos en el Principio de Responsabilidad Única (Single Responsibility Principle - SRP), que establece que:

“Una clase debe tener una única razón para cambiar”

El Problema: Implementación Típica

Veamos cómo muchos desarrolladores implementan inicialmente la creación de suscripciones en Stripe:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Stripe\Stripe;
use Stripe\Customer;
use Stripe\Subscription;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use App\Mail\SubscriptionCreated;

class SubscriptionController extends Controller
{
    public function subscribe(Request $request)
    {
        // Validación
        $validated = $request->validate([
            'payment_method' => 'required|string',
            'plan_id' => 'required|string',
        ]);

        $user = auth()->user();

        try {
            // Configurar Stripe
            Stripe::setApiKey(config('services.stripe.secret'));

            // Crear o recuperar cliente de Stripe
            if (!$user->stripe_customer_id) {
                $customer = Customer::create([
                    'email' => $user->email,
                    'name' => $user->name,
                    'payment_method' => $validated['payment_method'],
                    'invoice_settings' => [
                        'default_payment_method' => $validated['payment_method'],
                    ],
                ]);

                $user->update(['stripe_customer_id' => $customer->id]);
            } else {
                $customer = Customer::retrieve($user->stripe_customer_id);
            }

            // Crear suscripción
            $subscription = Subscription::create([
                'customer' => $customer->id,
                'items' => [['price' => $validated['plan_id']]],
                'expand' => ['latest_invoice.payment_intent'],
            ]);

            // Guardar en base de datos
            $user->subscriptions()->create([
                'stripe_subscription_id' => $subscription->id,
                'stripe_plan_id' => $validated['plan_id'],
                'status' => $subscription->status,
                'trial_ends_at' => $subscription->trial_end
                    ? now()->setTimestamp($subscription->trial_end)
                    : null,
                'ends_at' => null,
            ]);

            // Registrar en logs
            Log::info('Subscription created', [
                'user_id' => $user->id,
                'subscription_id' => $subscription->id,
            ]);

            // Enviar email de confirmación
            Mail::to($user)->send(new SubscriptionCreated($user, $subscription));

            // Aplicar lógica de negocio post-suscripción
            if ($subscription->status === 'active') {
                $user->update(['premium_until' => now()->addMonth()]);
                $user->assignRole('premium');
            }

            return response()->json([
                'success' => true,
                'subscription' => $subscription,
            ]);

        } catch (\Exception $e) {
            Log::error('Subscription failed', [
                'user_id' => $user->id,
                'error' => $e->getMessage(),
            ]);

            return response()->json([
                'success' => false,
                'error' => 'No se pudo crear la suscripción',
            ], 500);
        }
    }
}

¿Qué está mal con este código?

Este controlador viola el SRP porque tiene múltiples responsabilidades:

  1. Validación de entrada
  2. Comunicación con la API de Stripe
  3. Gestión de clientes de Stripe
  4. Creación de suscripciones
  5. Persistencia en base de datos
  6. Logging de eventos
  7. Envío de emails
  8. Lógica de negocio (roles, fechas premium)
  9. Gestión de errores

Problemas que esto causa:

  • 🔴 Difícil de testear: Necesitas mockear Stripe, Mail, Log, DB en cada test
  • 🔴 Imposible de reutilizar: No puedes crear suscripciones desde CLI, Jobs, etc.
  • 🔴 Difícil de mantener: Un cambio en Stripe afecta el controlador completo
  • 🔴 Violación del SRP: Tiene 9 razones diferentes para cambiar

La Solución: Aplicando SRP

Vamos a refactorizar este código separando las responsabilidades. Pero primero, entendamos por qué movemos cada parte a un lugar específico:

Arquitectura de Capas en Laravel

Services (Servicios): Encapsulan la comunicación con servicios externos (APIs, SDKs). El StripeSubscriptionService se encarga SOLO de hablar con Stripe, sin mezclar lógica de negocio.

Repositories (Repositorios): Manejan la persistencia de datos. El SubscriptionRepository se encarga SOLO de guardar/recuperar datos de la base de datos, siguiendo el patrón Repository.

Actions (Acciones): Orquestan la lógica de negocio. El CreateSubscriptionAction coordina los pasos necesarios para crear una suscripción, combinando servicios y repositorios.

Listeners (Escuchadores): Manejan efectos secundarios. Los listeners reaccionan a eventos y ejecutan tareas complementarias (enviar emails, actualizar roles) sin acoplar la lógica principal.

Form Requests: Encapsulan las reglas de validación. Separan la validación de la lógica del controlador, haciendo el código más limpio y reutilizable.

Controllers (Controladores): Punto de entrada HTTP. Solo delegan a las acciones, manteniendo la lógica de negocio fuera del controlador.

Ahora veamos el código refactorizado:

1. Servicio de Stripe (Comunicación con API Externa)

<?php

namespace App\Services\Stripe;

use Stripe\Stripe;
use Stripe\Customer;
use Stripe\Subscription;

class StripeSubscriptionService
{
    public function __construct()
    {
        Stripe::setApiKey(config('services.stripe.secret'));
    }

    public function createOrRetrieveCustomer(string $email, string $name, ?string $stripeCustomerId = null): Customer
    {
        if ($stripeCustomerId) {
            return Customer::retrieve($stripeCustomerId);
        }

        return Customer::create([
            'email' => $email,
            'name' => $name,
        ]);
    }

    public function attachPaymentMethod(string $customerId, string $paymentMethodId): void
    {
        Customer::update($customerId, [
            'invoice_settings' => [
                'default_payment_method' => $paymentMethodId,
            ],
        ]);
    }

    public function createSubscription(string $customerId, string $planId): Subscription
    {
        return Subscription::create([
            'customer' => $customerId,
            'items' => [['price' => $planId]],
            'expand' => ['latest_invoice.payment_intent'],
        ]);
    }
}

2. Repositorio de Suscripciones (Persistencia de Datos)

<?php

namespace App\Repositories;

use App\Models\User;
use App\Models\Subscription as SubscriptionModel;
use Stripe\Subscription as StripeSubscription;

class SubscriptionRepository
{
    public function createFromStripe(User $user, StripeSubscription $stripeSubscription, string $planId): SubscriptionModel
    {
        return $user->subscriptions()->create([
            'stripe_subscription_id' => $stripeSubscription->id,
            'stripe_plan_id' => $planId,
            'status' => $stripeSubscription->status,
            'trial_ends_at' => $stripeSubscription->trial_end
                ? now()->setTimestamp($stripeSubscription->trial_end)
                : null,
            'ends_at' => null,
        ]);
    }
}

3. Acción de Negocio (Orquestación)

<?php

namespace App\Actions\Subscriptions;

use App\Models\User;
use App\Services\Stripe\StripeSubscriptionService;
use App\Repositories\SubscriptionRepository;
use App\Events\SubscriptionCreated;
use Illuminate\Support\Facades\DB;

class CreateSubscriptionAction
{
    public function __construct(
        private StripeSubscriptionService $stripeService,
        private SubscriptionRepository $subscriptionRepo,
    ) {}

    public function execute(User $user, string $paymentMethodId, string $planId): array
    {
        return DB::transaction(function () use ($user, $paymentMethodId, $planId) {
            // 1. Gestionar cliente de Stripe
            $customer = $this->stripeService->createOrRetrieveCustomer(
                $user->email,
                $user->name,
                $user->stripe_customer_id
            );

            if (!$user->stripe_customer_id) {
                $user->update(['stripe_customer_id' => $customer->id]);
            }

            // 2. Adjuntar método de pago
            $this->stripeService->attachPaymentMethod($customer->id, $paymentMethodId);

            // 3. Crear suscripción en Stripe
            $stripeSubscription = $this->stripeService->createSubscription(
                $customer->id,
                $planId
            );

            // 4. Persistir en base de datos
            $subscription = $this->subscriptionRepo->createFromStripe(
                $user,
                $stripeSubscription,
                $planId
            );

            // 5. Disparar evento (observers se encargarán del resto)
            event(new SubscriptionCreated($user, $subscription));

            return [
                'subscription' => $subscription,
                'stripe_subscription' => $stripeSubscription,
            ];
        });
    }
}

4. Form Request (Validación)

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class SubscribeRequest extends FormRequest
{
    public function authorize(): bool
    {
        return auth()->check();
    }

    public function rules(): array
    {
        return [
            'payment_method' => 'required|string',
            'plan_id' => 'required|string|exists:plans,id',
        ];
    }

    public function messages(): array
    {
        return [
            'payment_method.required' => 'El método de pago es requerido',
            'plan_id.required' => 'El plan es requerido',
            'plan_id.exists' => 'El plan seleccionado no existe',
        ];
    }
}

5. Controlador Simplificado (Punto de Entrada HTTP)

<?php

namespace App\Http\Controllers;

use App\Http\Requests\SubscribeRequest;
use App\Actions\Subscriptions\CreateSubscriptionAction;
use Illuminate\Support\Facades\Log;

class SubscriptionController extends Controller
{
    public function __construct(
        private CreateSubscriptionAction $createSubscription
    ) {}

    public function subscribe(SubscribeRequest $request)
    {
        // La validación ya ocurrió en el FormRequest
        try {
            $result = $this->createSubscription->execute(
                auth()->user(),
                $request->validated('payment_method'),
                $request->validated('plan_id')
            );

            return response()->json([
                'success' => true,
                'subscription' => $result['subscription'],
            ]);

        } catch (\Exception $e) {
            Log::error('Subscription failed', [
                'user_id' => auth()->id(),
                'error' => $e->getMessage(),
            ]);

            return response()->json([
                'success' => false,
                'error' => 'No se pudo crear la suscripción',
            ], 500);
        }
    }
}

6. Event Listeners (Efectos Secundarios)

¿Por qué separar en listeners? Los efectos secundarios como enviar emails o actualizar roles no son parte del flujo principal. Si fallan, no deben romper la suscripción. Los eventos permiten desacoplar estas responsabilidades.

<?php

namespace App\Listeners;

use App\Events\SubscriptionCreated;
use Illuminate\Support\Facades\Mail;
use App\Mail\SubscriptionCreatedMail;

class SendSubscriptionEmail
{
    public function handle(SubscriptionCreated $event): void
    {
        Mail::to($event->user)->send(
            new SubscriptionCreatedMail($event->user, $event->subscription)
        );
    }
}
<?php

namespace App\Listeners;

use App\Events\SubscriptionCreated;

class ActivatePremiumFeatures
{
    public function handle(SubscriptionCreated $event): void
    {
        if ($event->subscription->status === 'active') {
            $event->user->update([
                'premium_until' => now()->addMonth()
            ]);
            $event->user->assignRole('premium');
        }
    }
}

Comparación

Antes (Violando SRP)

  • ❌ 1 clase con 9 responsabilidades
  • ❌ 150+ líneas en el controlador
  • ❌ Validación mezclada con lógica
  • ❌ Imposible de testear unitariamente
  • ❌ Lógica de negocio mezclada con infraestructura

Después (Aplicando SRP)

  • ✅ 6 clases, cada una con 1 responsabilidad clara
  • ✅ Controlador de ~20 líneas
  • ✅ Validación separada en FormRequest
  • ✅ Cada clase es fácil de testear
  • ✅ Lógica de negocio separada de infraestructura
  • ✅ Reutilizable desde cualquier parte (CLI, Jobs, etc.)

Beneficios Obtenidos

1. Testeabilidad

// Ahora puedes testear cada pieza independientemente
public function test_stripe_service_creates_customer()
{
    $service = new StripeSubscriptionService();
    $customer = $service->createOrRetrieveCustomer('test@test.com', 'Test User');

    $this->assertNotNull($customer->id);
}

2. Reutilización

// Usar desde un comando Artisan
class UpgradeUserCommand extends Command
{
    public function handle(CreateSubscriptionAction $action)
    {
        $user = User::find($this->argument('user_id'));
        $action->execute($user, $paymentMethod, $planId);
    }
}

3. Mantenibilidad

  • Cambios en Stripe → Solo modificas StripeSubscriptionService
  • Cambios en DB → Solo modificas SubscriptionRepository
  • Cambios en emails → Solo modificas el Listener

Conclusión

El Principio de Responsabilidad Única no significa que una clase deba hacer solo una cosa, sino que debe tener una única razón para cambiar. Al aplicarlo:

  • Tus clases son más pequeñas y enfocadas
  • Tu código es más fácil de testear
  • Los cambios son más seguros y localizados
  • Tu aplicación es más mantenible a largo plazo

En el próximo artículo exploraremos el Principio Open/Closed, donde veremos cómo extender el comportamiento de pagos sin modificar el código existente.

Recursos


Próximo artículo: SOLID en Laravel (2/5): Principio Open/Closed

☕ ¿Te ha sido útil este artículo?

Apóyame con un café mientras sigo creando contenido técnico

☕ Invítame un café