Oscar Coleto

Patrón Factory Avanzado en Laravel: Container, Service Locator y Abstract Factory

Introducción

En el artículo anterior sobre el Patrón Factory, aprendimos los fundamentos de este patrón de diseño y cómo implementarlo en Laravel de forma básica. Vimos cómo crear Factories simples usando match y new para instanciar objetos.

Pero Laravel ofrece herramientas mucho más poderosas que podemos aprovechar para llevar nuestras Factories al siguiente nivel. En este artículo exploraremos técnicas avanzadas que te permitirán crear Factories más flexibles, escalables y mantenibles.

¿Qué veremos en este artículo?

  • Factory con el Container de Laravel: Inyección automática de dependencias
  • Factory vs Service Locator: Evitando anti-patrones
  • Abstract Factory: Familias de objetos relacionados
  • Factory con caché: Optimización de instancias
  • Factory dinámica: Registro de implementaciones en runtime
  • Mejores prácticas avanzadas

Si aún no has leído el artículo básico, te recomiendo empezar por ahí: Patrón Factory en Laravel.

Factory Avanzada: Usando el Container de Laravel

Una de las características más poderosas de Laravel es su Container de Inyección de Dependencias. Veamos cómo aprovecharlo en nuestras Factories.

El Problema con la Instanciación Manual

En el artículo anterior, nuestras Factories instanciaban objetos directamente:

class NotifierFactory
{
    public function make(string $type): NotifierInterface
    {
        return match ($type) {
            'email' => new EmailNotifier(),
            'sms' => new SmsNotifier(),
            'push' => new PushNotifier(),
            default => throw new InvalidArgumentException("Tipo no soportado: {$type}")
        };
    }
}

Problemas de este enfoque:

Si EmailNotifier necesita dependencias, debemos pasarlas manualmente

No aprovechamos el Container de Laravel

Dificulta el testing y la configuración

Solución: Factory con Container

<?php

namespace App\Factories;

use App\Contracts\NotifierInterface;
use Illuminate\Contracts\Container\Container;
use InvalidArgumentException;

class NotifierFactory
{
    private array $notifiers = [
        'email' => \App\Services\Notifications\EmailNotifier::class,
        'sms' => \App\Services\Notifications\SmsNotifier::class,
        'push' => \App\Services\Notifications\PushNotifier::class,
        'slack' => \App\Services\Notifications\SlackNotifier::class,
    ];

    public function __construct(
        private Container $container
    ) {}

    public function make(string $type): NotifierInterface
    {
        if (!isset($this->notifiers[$type])) {
            throw new InvalidArgumentException("Tipo de notificador no soportado: {$type}");
        }

        // Laravel resuelve automáticamente las dependencias
        return $this->container->make($this->notifiers[$type]);
    }

    /**
     * Registrar un nuevo tipo de notificador dinámicamente.
     */
    public function extend(string $type, string $class): self
    {
        $this->notifiers[$type] = $class;
        return $this;
    }

    /**
     * Verificar si un tipo está registrado.
     */
    public function hasType(string $type): bool
    {
        return isset($this->notifiers[$type]);
    }

    /**
     * Obtener todos los tipos disponibles.
     */
    public function getAvailableTypes(): array
    {
        return array_keys($this->notifiers);
    }
}

Ventajas de este enfoque:

Inyección automática: Laravel resuelve todas las dependencias

Extensibilidad: Método extend() para agregar tipos dinámicamente

Introspección: Métodos para verificar y listar tipos disponibles

Testing: Fácil de mockear y testear

Ejemplo: Notifier con Dependencias

Ahora EmailNotifier puede tener todas las dependencias que necesite:

<?php

namespace App\Services\Notifications;

use App\Contracts\NotifierInterface;
use Illuminate\Mail\Mailer;
use Illuminate\Contracts\Queue\Queue;
use Psr\Log\LoggerInterface;

class EmailNotifier implements NotifierInterface
{
    public function __construct(
        private Mailer $mailer,
        private Queue $queue,
        private LoggerInterface $logger
    ) {}

    public function send(string $recipient, string $message): bool
    {
        try {
            $this->queue->push(function() use ($recipient, $message) {
                $this->mailer->raw($message, function($msg) use ($recipient) {
                    $msg->to($recipient);
                });
            });

            $this->logger->info("Email queued", ['recipient' => $recipient]);
            return true;

        } catch (\Exception $e) {
            $this->logger->error("Email failed", ['error' => $e->getMessage()]);
            return false;
        }
    }
}

Cuando usamos $container->make(EmailNotifier::class), Laravel inyecta automáticamente Mailer, Queue y LoggerInterface.

Registrar la Factory en un Service Provider

Para que nuestra Factory esté disponible en toda la aplicación, la registramos en un Service Provider:

<?php

namespace App\Providers;

use App\Factories\NotifierFactory;
use Illuminate\Support\ServiceProvider;

class NotificationServiceProvider extends ServiceProvider
{
    /**
     * Registrar servicios en el container.
     */
    public function register(): void
    {
        // Registrar la Factory como singleton
        $this->app->singleton(NotifierFactory::class, function ($app) {
            return new NotifierFactory($app);
        });
    }

    /**
     * Bootstrap de servicios (opcional).
     */
    public function boot(): void
    {
        // Extender la factory con tipos personalizados si es necesario
        $factory = $this->app->make(NotifierFactory::class);

        if (config('notifications.enable_telegram')) {
            $factory->extend('telegram', \App\Services\Notifications\TelegramNotifier::class);
        }

        if (config('notifications.enable_whatsapp')) {
            $factory->extend('whatsapp', \App\Services\Notifications\WhatsAppNotifier::class);
        }
    }
}

No olvides registrar el provider en config/app.php:

'providers' => [
    // ...
    App\Providers\NotificationServiceProvider::class,
],

Factory Pattern vs Service Locator

Es crucial entender la diferencia entre el patrón Factory y el anti-patrón Service Locator.

Factory Pattern (Correcto)

<?php

namespace App\Services;

use App\Factories\NotifierFactory;

class NotificationService
{
    public function __construct(
        private NotifierFactory $factory  // ✓ Dependencia explícita
    ) {}

    public function sendWelcomeEmail(User $user): void
    {
        $notifier = $this->factory->make('email');
        $notifier->send($user->email, "Bienvenido {$user->name}!");
    }

    public function sendSmsVerification(User $user, string $code): void
    {
        $notifier = $this->factory->make('sms');
        $notifier->send($user->phone, "Tu código es: {$code}");
    }
}

Por qué es correcto:

La dependencia está declarada explícitamente en el constructor

Es fácil de testear (puedes inyectar un mock)

Es obvio qué dependencias tiene la clase

Sigue el principio de inversión de dependencias (DIP)

Service Locator (Anti-patrón)

<?php

namespace App\Services;

class NotificationService
{
    public function sendWelcomeEmail(User $user): void
    {
        // ✗ Dependencia oculta
        $notifier = app()->make('notifier.email');
        $notifier->send($user->email, "Bienvenido {$user->name}!");
    }

    public function sendSmsVerification(User $user, string $code): void
    {
        // ✗ Dependencia oculta
        $notifier = resolve('notifier.sms');
        $notifier->send($user->phone, "Tu código es: {$code}");
    }
}

Por qué es un anti-patrón:

Las dependencias están ocultas

Difícil de testear (no puedes inyectar mocks fácilmente)

Acoplamiento al framework (usa helpers específicos de Laravel)

No es obvio qué dependencias necesita

La Regla de Oro

Siempre inyecta la Factory en el constructor. Nunca uses app(), resolve() u otros Service Locators dentro de tus clases.

Excepción: En archivos de configuración, migrations, seeders o Service Providers, usar el container directamente está bien, ya que son puntos de entrada de la aplicación.

Abstract Factory: Familias de Objetos Relacionados

El Abstract Factory es un patrón más avanzado que permite crear familias de objetos relacionados sin especificar sus clases concretas.

Caso de Uso: Sistema Multi-Tenant con Diferentes Proveedores

Imagina que tienes una aplicación multi-tenant donde cada tenant puede usar diferentes proveedores para email, SMS y almacenamiento.

<?php

namespace App\Contracts;

/**
 * Abstract Factory para crear familias de servicios.
 */
interface ServiceFactoryInterface
{
    public function createEmailService(): EmailServiceInterface;
    public function createSmsService(): SmsServiceInterface;
    public function createStorageService(): StorageServiceInterface;
}

Implementaciones Concretas

Factory para AWS:

<?php

namespace App\Factories\ServiceProviders;

use App\Contracts\ServiceFactoryInterface;
use App\Services\Aws\SesEmailService;
use App\Services\Aws\SnsSmsService;
use App\Services\Aws\S3StorageService;

class AwsServiceFactory implements ServiceFactoryInterface
{
    public function createEmailService(): EmailServiceInterface
    {
        return new SesEmailService(
            config('services.aws.ses_key'),
            config('services.aws.ses_secret')
        );
    }

    public function createSmsService(): SmsServiceInterface
    {
        return new SnsSmsService(
            config('services.aws.sns_key'),
            config('services.aws.sns_secret')
        );
    }

    public function createStorageService(): StorageServiceInterface
    {
        return new S3StorageService(
            config('services.aws.s3_bucket')
        );
    }
}

Factory para Google Cloud:

<?php

namespace App\Factories\ServiceProviders;

use App\Contracts\ServiceFactoryInterface;
use App\Services\Google\GmailEmailService;
use App\Services\Google\FirebaseSmsService;
use App\Services\Google\CloudStorageService;

class GoogleServiceFactory implements ServiceFactoryInterface
{
    public function createEmailService(): EmailServiceInterface
    {
        return new GmailEmailService(
            config('services.google.gmail_credentials')
        );
    }

    public function createSmsService(): SmsServiceInterface
    {
        return new FirebaseSmsService(
            config('services.google.firebase_credentials')
        );
    }

    public function createStorageService(): StorageServiceInterface
    {
        return new CloudStorageService(
            config('services.google.storage_bucket')
        );
    }
}

Factory de Factories

Ahora necesitamos una Factory que decida qué Abstract Factory usar:

<?php

namespace App\Factories;

use App\Contracts\ServiceFactoryInterface;
use App\Factories\ServiceProviders\AwsServiceFactory;
use App\Factories\ServiceProviders\GoogleServiceFactory;
use App\Factories\ServiceProviders\AzureServiceFactory;
use InvalidArgumentException;

class ServiceProviderFactory
{
    public function make(string $provider): ServiceFactoryInterface
    {
        return match ($provider) {
            'aws' => new AwsServiceFactory(),
            'google' => new GoogleServiceFactory(),
            'azure' => new AzureServiceFactory(),
            default => throw new InvalidArgumentException("Proveedor no soportado: {$provider}")
        };
    }

    public function makeForTenant(int $tenantId): ServiceFactoryInterface
    {
        $provider = DB::table('tenants')
            ->where('id', $tenantId)
            ->value('service_provider');

        return $this->make($provider);
    }
}

Uso en un Servicio

<?php

namespace App\Services;

use App\Factories\ServiceProviderFactory;

class TenantService
{
    public function __construct(
        private ServiceProviderFactory $providerFactory
    ) {}

    public function sendWelcomePackage(int $tenantId, User $user): void
    {
        // Obtener la factory específica del tenant
        $factory = $this->providerFactory->makeForTenant($tenantId);

        // Crear servicios de la familia correspondiente
        $emailService = $factory->createEmailService();
        $smsService = $factory->createSmsService();

        // Usar los servicios
        $emailService->send($user->email, 'Bienvenido!');
        $smsService->send($user->phone, 'Gracias por registrarte');
    }
}

Ventaja: Cambiar de proveedor es transparente. Todos los servicios (email, SMS, storage) cambian juntos de forma consistente.

Factory con Caché de Instancias

A veces quieres que una Factory reutilice instancias en lugar de crear una nueva cada vez (patrón Flyweight).

<?php

namespace App\Factories;

use App\Contracts\PaymentGatewayInterface;
use Illuminate\Contracts\Container\Container;

class CachedPaymentGatewayFactory
{
    private array $gateways = [
        'stripe' => \App\Services\Payments\StripeGateway::class,
        'paypal' => \App\Services\Payments\PayPalGateway::class,
        'mercadopago' => \App\Services\Payments\MercadoPagoGateway::class,
    ];

    private array $instances = [];

    public function __construct(
        private Container $container
    ) {}

    /**
     * Obtener una instancia (cacheada).
     */
    public function make(string $gateway): PaymentGatewayInterface
    {
        // Si ya existe, reutilizarla
        if (isset($this->instances[$gateway])) {
            return $this->instances[$gateway];
        }

        if (!isset($this->gateways[$gateway])) {
            throw new InvalidArgumentException("Gateway no soportado: {$gateway}");
        }

        // Crear y cachear la instancia
        $this->instances[$gateway] = $this->container->make($this->gateways[$gateway]);

        return $this->instances[$gateway];
    }

    /**
     * Crear una instancia fresca sin cachear.
     */
    public function makeFresh(string $gateway): PaymentGatewayInterface
    {
        if (!isset($this->gateways[$gateway])) {
            throw new InvalidArgumentException("Gateway no soportado: {$gateway}");
        }

        return $this->container->make($this->gateways[$gateway]);
    }

    /**
     * Limpiar el caché.
     */
    public function clearCache(): void
    {
        $this->instances = [];
    }
}

Cuándo usar caché:

Los objetos son stateless (sin estado)

La creación es costosa (conexiones a APIs, inicializaciones pesadas)

Quieres reutilizar conexiones (pools de conexiones)

Cuándo NO usar caché:

Los objetos tienen estado mutable

Necesitas instancias independientes por request

Los objetos almacenan datos específicos del contexto

Factory con Configuración Externa

Puedes hacer que las Factories sean completamente configurables desde archivos de configuración:

Archivo de Configuración

<?php

// config/notifications.php

return [
    'default' => env('NOTIFICATION_CHANNEL', 'email'),

    'channels' => [
        'email' => [
            'driver' => \App\Services\Notifications\EmailNotifier::class,
            'enabled' => true,
            'queue' => true,
        ],
        'sms' => [
            'driver' => \App\Services\Notifications\SmsNotifier::class,
            'enabled' => env('SMS_ENABLED', false),
            'queue' => true,
        ],
        'push' => [
            'driver' => \App\Services\Notifications\PushNotifier::class,
            'enabled' => env('PUSH_ENABLED', false),
            'queue' => false,
        ],
        'slack' => [
            'driver' => \App\Services\Notifications\SlackNotifier::class,
            'enabled' => env('SLACK_ENABLED', false),
            'queue' => false,
        ],
    ],
];

Factory que Lee la Configuración

<?php

namespace App\Factories;

use App\Contracts\NotifierInterface;
use Illuminate\Contracts\Container\Container;
use InvalidArgumentException;

class ConfigurableNotifierFactory
{
    private array $channels;

    public function __construct(
        private Container $container
    ) {
        $this->channels = config('notifications.channels', []);
    }

    public function make(string $channel): NotifierInterface
    {
        if (!isset($this->channels[$channel])) {
            throw new InvalidArgumentException("Canal no configurado: {$channel}");
        }

        $config = $this->channels[$channel];

        if (!$config['enabled']) {
            throw new InvalidArgumentException("Canal deshabilitado: {$channel}");
        }

        return $this->container->make($config['driver']);
    }

    public function makeDefault(): NotifierInterface
    {
        return $this->make(config('notifications.default'));
    }

    public function getEnabledChannels(): array
    {
        return array_keys(array_filter($this->channels, fn($c) => $c['enabled']));
    }

    public function isChannelEnabled(string $channel): bool
    {
        return $this->channels[$channel]['enabled'] ?? false;
    }
}

Factory con Resolución por Convención

Puedes hacer que una Factory resuelva clases automáticamente siguiendo una convención de nombres:

<?php

namespace App\Factories;

use App\Contracts\ExporterInterface;
use Illuminate\Contracts\Container\Container;
use Illuminate\Support\Str;

class ExporterFactory
{
    private string $namespace = 'App\\Services\\Exporters\\';

    public function __construct(
        private Container $container
    ) {}

    /**
     * Crear un exportador siguiendo la convención de nombres.
     *
     * Ejemplos:
     * - 'pdf' -> App\Services\Exporters\PdfExporter
     * - 'excel' -> App\Services\Exporters\ExcelExporter
     * - 'csv' -> App\Services\Exporters\CsvExporter
     */
    public function make(string $format): ExporterInterface
    {
        $className = $this->namespace . Str::studly($format) . 'Exporter';

        if (!class_exists($className)) {
            throw new InvalidArgumentException("Exportador no encontrado para formato: {$format}");
        }

        $instance = $this->container->make($className);

        if (!$instance instanceof ExporterInterface) {
            throw new InvalidArgumentException("{$className} debe implementar ExporterInterface");
        }

        return $instance;
    }
}

Ventajas:

No necesitas registrar cada tipo manualmente

Agregar nuevos exportadores es automático

Código más DRY

Desventajas:

Menos explícito

Depende de convenciones de nombres

Errores en tiempo de ejecución si no se sigue la convención

Testing Avanzado con Factories

Mockear una Factory Completa

<?php

namespace Tests\Feature;

use App\Factories\NotifierFactory;
use App\Contracts\NotifierInterface;
use Tests\TestCase;
use Mockery;

class NotificationTest extends TestCase
{
    public function test_sends_notification_via_factory()
    {
        // Crear un mock del notificador
        $mockNotifier = Mockery::mock(NotifierInterface::class);
        $mockNotifier->shouldReceive('send')
            ->once()
            ->with('[email protected]', 'Test message')
            ->andReturn(true);

        // Crear un mock de la factory
        $mockFactory = Mockery::mock(NotifierFactory::class);
        $mockFactory->shouldReceive('make')
            ->with('email')
            ->once()
            ->andReturn($mockNotifier);

        // Reemplazar la factory en el container
        $this->app->instance(NotifierFactory::class, $mockFactory);

        // Ejecutar el test
        $response = $this->postJson('/notifications', [
            'type' => 'email',
            'recipient' => '[email protected]',
            'message' => 'Test message'
        ]);

        $response->assertOk();
    }
}

Testing con Fakes de Laravel

Si tu Factory crea servicios de Laravel (Mail, Queue, etc.), puedes usar los fakes:

<?php

namespace Tests\Feature;

use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use App\Factories\NotifierFactory;
use Tests\TestCase;

class EmailNotificationTest extends TestCase
{
    public function test_email_notifier_queues_email()
    {
        Mail::fake();
        Queue::fake();

        $factory = app(NotifierFactory::class);
        $notifier = $factory->make('email');

        $notifier->send('[email protected]', 'Hello!');

        Queue::assertPushed(/* ... */);
    }
}

Mejores Prácticas Avanzadas

1. Usa Type Hints Estrictos

public function make(string $type): NotifierInterface
{
    // El tipo de retorno garantiza que siempre devuelves la interfaz correcta
}

2. Valida Configuraciones en el Constructor

public function __construct(Container $container)
{
    $this->container = $container;
    $this->channels = config('notifications.channels');

    // Validar que la configuración sea correcta
    if (empty($this->channels)) {
        throw new RuntimeException("No hay canales de notificación configurados");
    }
}

3. Proporciona Métodos Helper Útiles

public function makeDefault(): NotifierInterface
{
    return $this->make(config('notifications.default'));
}

public function makeMultiple(array $types): array
{
    return array_map(fn($type) => $this->make($type), $types);
}

public function canMake(string $type): bool
{
    return isset($this->notifiers[$type]);
}

4. Documenta Extensamente

/**
 * Factory para crear instancias de notificadores.
 *
 * Esta factory soporta múltiples canales de notificación configurables
 * mediante el archivo config/notifications.php. Utiliza el Container de
 * Laravel para resolver automáticamente las dependencias.
 *
 * @example
 * $notifier = $factory->make('email');
 * $notifier->send('user@example.com', 'Hello!');
 *
 * @see \App\Contracts\NotifierInterface
 */
class NotifierFactory
{
    // ...
}

5. Considera el Performance

// ✓ Bueno: Cachear configuración pesada
private array $notifiers;

public function __construct(Container $container)
{
    $this->notifiers = config('notifications.channels');
}

// ✗ Malo: Leer configuración en cada llamada
public function make(string $type): NotifierInterface
{
    $config = config('notifications.channels'); // ← Se ejecuta cada vez
    // ...
}

6. Maneja Errores Apropiadamente

public function make(string $type): NotifierInterface
{
    if (!isset($this->notifiers[$type])) {
        throw new InvalidArgumentException(
            "Tipo de notificador no soportado: {$type}. " .
            "Tipos disponibles: " . implode(', ', array_keys($this->notifiers))
        );
    }

    try {
        return $this->container->make($this->notifiers[$type]);
    } catch (BindingResolutionException $e) {
        throw new RuntimeException(
            "No se pudo crear el notificador {$type}: {$e->getMessage()}",
            previous: $e
        );
    }
}

Comparación: Cuándo Usar Cada Técnica

TécnicaCuándo UsarComplejidad
Factory Simple (new)Objetos sin dependenciasBaja
Factory con ContainerObjetos con dependenciasMedia
Factory con CachéObjetos costosos de crearMedia
Abstract FactoryFamilias de objetos relacionadosAlta
Factory ConfigurableComportamiento variable por entornoMedia
Factory por ConvenciónMuchas implementaciones similaresMedia-Alta

Conclusión

El patrón Factory en Laravel puede ser tan simple o tan sofisticado como lo necesites. Hemos visto técnicas avanzadas que incluyen:

Container de Laravel para inyección automática de dependencias

Diferencia entre Factory y Service Locator (evita el anti-patrón)

Abstract Factory para familias de objetos

Caché de instancias para optimización

Configuración externa para flexibilidad

Resolución por convención para DRY

Testing avanzado con mocks y fakes

Recuerda:

  • Inyecta la Factory, no uses Service Locators
  • Aprovecha el Container de Laravel para resolver dependencias
  • Documenta bien tus factories y sus capacidades
  • Elige la técnica apropiada según la complejidad de tu caso
  • No sobre-ingenierices: usa la técnica más simple que resuelva tu problema

La maestría del patrón Factory viene de saber cuándo aplicar cada técnica y cuándo mantener las cosas simples.


Artículos Relacionados

Happy coding! 🚀

C O M E N T A R I O S

Deja un comentario

0/2000 caracteres

Tu email no será publicado. Los campos marcados con * son obligatorios.

Cargando comentarios...

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

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

☕ Invítame un café