Skip to content

Handlers: Implementación de Nuevos Jobs

◄ Anterior: API Endpoints | Índice | Siguiente: Multi-Tenant ►


Tabla de Contenidos


Interface JobHandlerInterface

Ubicación: Core/Interfaces/JobHandlerInterface.php

Namespace: App\Core\Interfaces

Propósito: Contrato que deben cumplir todos los handlers de jobs

Contrato:

php
interface JobHandlerInterface
{
    /**
     * Obtener tipo de job que maneja este handler
     *
     * @return string Tipo de job (ej: 'batch_invoicing')
     */
    public function getType(): string;

    /**
     * Ejecutar job con payload dado
     *
     * @param array $payload Datos necesarios para ejecutar
     * @param callable|null $onProgress Callback opcional para reportar progreso (0.0 a 100.0)
     * @return array Resultado del job
     * @throws Exception Si falla la ejecución
     */
    public function handle(array $payload, ?callable $onProgress = null): array;
}

Nota sobre backward compatibility: El parámetro $onProgress es opcional (default null). Handlers existentes que implementan handle(array $payload) siguen funcionando sin cambios. Nuevos handlers pueden usar $onProgress(float $percentage) para reportar progreso al JobExecutor.


Ejemplo: BatchInvoicingJobHandler

Ubicación: Ventas/Handlers/BatchInvoicingJobHandler.php

Namespace: App\Ventas\Handlers

Propósito: Handler para facturación masiva (ejemplo de implementación)

Type: 'batch_invoicing'

Payload esperado:

php
[
    'cliente_ids' => [1, 2, 3, 4, 5],
    'fecha' => '2026-02-05',
    'concepto' => 'Facturación mensual',
    'monto_base' => 1000.00
]

Result retornado:

php
[
    'facturas_creadas' => 5,
    'monto_total' => 5000.00,
    'factura_ids' => [101, 102, 103, 104, 105],
    'errores' => [] // Clientes que fallaron
]

Reporte de progreso — Progreso por fases (NO por factura individual):

El plan original contemplaba progreso por factura individual:

php
// ❌ Plan original - NO implementado
foreach ($facturas as $index => $facturaId) {
    $progress = (($index + 1) / $total) * 100;
    $this->jobRunner->setProgress($this->jobId, $progress);
}

Esto NO es posible porque BatchInvoicingOrchestrator es una operación atómica tipo "black-box" (ver ADR-004: Wrapper Pattern). El handler NO puede inyectar callbacks dentro del orquestador sin modificarlo, lo cual violaría ADR-004.

Implementación real — Progreso por fases:

  • 10%: Payload validado, inicio de procesamiento
  • 50%: Delegación a BatchInvoicingOrchestrator iniciada
  • 100%: Orquestador terminó (éxito o error)
php
// ✅ Implementación real - progreso por fases
public function handle(array $payload, ?callable $onProgress = null): array
{
    $this->validatePayload($payload);
    if ($onProgress) $onProgress(10.0);  // Fase 1: Validación completada

    if ($onProgress) $onProgress(50.0);  // Fase 2: Inicio de procesamiento
    $result = $this->orchestrator->execute($payload);

    if ($onProgress) $onProgress(100.0); // Fase 3: Completado
    return $result;
}

Implementación completa (referencia):

php
class BatchInvoicingJobHandler implements JobHandlerInterface
{
    private FacturaService $facturaService;

    public function __construct(FacturaService $facturaService)
    {
        $this->facturaService = $facturaService;
    }

    public function getType(): string
    {
        return 'batch_invoicing';
    }

    public function handle(array $payload, ?callable $onProgress = null): array
    {
        // 1. Validar payload
        $this->validatePayload($payload);

        // 2. Extraer datos
        $clienteIds = $payload['cliente_ids'];
        $fecha = $payload['fecha'];
        $concepto = $payload['concepto'];
        $montoBase = $payload['monto_base'];

        // 3. Procesar batch
        $facturasCreadas = [];
        $errores = [];

        if ($onProgress) $onProgress(10.0); // Validación completada

        if ($onProgress) $onProgress(50.0); // Inicio de procesamiento

        foreach ($clienteIds as $clienteId) {
            try {
                // 4. Reconstruir DTO que espera FacturaService::insert()
                $facturaDTO = new CreateFacturaDTO(
                    cliente_id: $clienteId,
                    fecha: $fecha,
                    items: [
                        [
                            'concepto' => $concepto,
                            'monto' => $montoBase
                        ]
                    ]
                );

                // 5. Delegar a service existente (NO modificado)
                $factura = $this->facturaService->insert($facturaDTO);

                $facturasCreadas[] = $factura->id;

            } catch (Exception $e) {
                $errores[] = [
                    'cliente_id' => $clienteId,
                    'error' => $e->getMessage()
                ];
            }
        }

        if ($onProgress) $onProgress(100.0); // Completado

        // 6. Retornar resultado consolidado
        return [
            'facturas_creadas' => count($facturasCreadas),
            'monto_total' => $montoBase * count($facturasCreadas),
            'factura_ids' => $facturasCreadas,
            'errores' => $errores
        ];
    }

    private function validatePayload(array $payload): void
    {
        $required = ['cliente_ids', 'fecha', 'concepto', 'monto_base'];
        foreach ($required as $field) {
            if (!isset($payload[$field])) {
                throw new InvalidArgumentException("Campo requerido: {$field}");
            }
        }
    }
}

Patrón Wrapper

Concepto: El handler NO modifica servicios existentes. En su lugar, envuelve (wraps) el service existente.

Puntos clave:

  1. NO modifica service existente: FacturaService queda intacto
  2. Reconstruye request DTO: Crea el mismo DTO que usaría el controller síncrono
  3. Delega lógica compleja: El service hace TODO el trabajo (validaciones, transacciones, etc.)
  4. Acumula resultados: Handler solo itera y consolida
  5. Manejo de errores: Captura exceptions por item, NO falla todo el batch

Ventajas del patrón:

  • ✅ CERO impacto en código existente
  • ✅ Service puede usarse sincrónicamente (original) o asincrónicamente (via handler)
  • ✅ Feature flag controlado (rollback instantáneo)
  • ✅ Fácil testing (unit tests del handler con mock del service)

Flujo visual:

Controller (síncrono)               JobHandler (asíncrono)
      ↓                                      ↓
  CreateDTO                              CreateDTO (x N)
      ↓                                      ↓
  Service::insert()  ← SHARED →    Service::insert() (x N)
      ↓                                      ↓
  Return DTO                          Accumulate results

Registro de Handlers: Auto-descubrimiento via DI

Mecanismo: Los handlers se registran mediante un array nombrado job.handlers en el DI container. JobHandlerRegistry consume este array y los indexa por tipo. No es necesario modificar JobExecutor ni JobDispatcher para agregar nuevos handlers.

Ubicacion del registro: container/shared-definitions.php

Flujo de resolucion:

shared-definitions.php
  ├── 'job.handlers' => [ get(Handler1::class), get(Handler2::class), ... ]
  ├── JobHandlerRegistry::class => factory(fn => new Registry($handlers))
  └── JobExecutor::class => autowire()  ← recibe Registry via constructor

JobHandlerRegistry itera los handlers y los indexa por getType():

php
class JobHandlerRegistry
{
    private array $handlers = [];

    public function __construct(array $handlers)
    {
        foreach ($handlers as $handler) {
            $this->handlers[$handler->getType()] = $handler;
        }
    }

    public function get(string $type): JobHandlerInterface { /* ... */ }
    public function has(string $type): bool { /* ... */ }
    public function getRegisteredTypes(): array { /* ... */ }
}

JobExecutor delega la resolucion de handlers al registry (no mantiene un array propio):

php
class JobExecutor
{
    public function __construct(
        private readonly JobRepository $jobRepo,
        private readonly NotificationService $notificationService,
        private readonly ConnectionManager $connectionManager,
        private readonly LoggerInterface $logger,
        private readonly JobHandlerRegistry $registry,
        // ...
    ) {}
}

Guia para Agregar Nuevos Handlers

Paso 1: Crear Clase Handler

Ubicacion: {Modulo}/Handlers/{NombreJobHandler}.php

php
namespace App\{Modulo}\Handlers;

use App\Core\Interfaces\JobHandlerInterface;

class {NombreJobHandler} implements JobHandlerInterface
{
    public function __construct(
        // Inyectar services necesarios
        private {RelevantService} $service
    ) {}

    public function getType(): string
    {
        return '{tipo_job}'; // Ej: 'generate_report'
    }

    public function handle(array $payload, ?callable $onProgress = null): array
    {
        // 1. Validar payload
        // 2. Extraer datos
        // 3. Procesar (delegar a service)
        //    if ($onProgress) $onProgress(50.0); // Opcional: reportar progreso
        // 4. Retornar resultado
    }
}

Paso 2: Registrar Handler en shared-definitions.php

Ubicacion: container/shared-definitions.php

Se requieren dos lineas: agregar al array job.handlers y declarar el autowire.

php
// container/shared-definitions.php
use App\{Modulo}\Handlers\{NombreJobHandler};

return [
    // ... definiciones existentes ...

    'job.handlers' => [
        get(BatchInvoicingJobHandler::class),
        get({NombreJobHandler}::class),           // ← AGREGAR al array
    ],

    // ... JobHandlerRegistry y JobExecutor no se modifican ...

    {NombreJobHandler}::class => autowire({NombreJobHandler}::class),  // ← AGREGAR autowire
];

Importante: NO es necesario modificar JobHandlerRegistry, JobExecutor ni JobDispatcher. El auto-descubrimiento se encarga de todo.


Paso 3: Testing

Ubicacion: Tests/Unit/{Modulo}/{NombreJobHandler}Test.php

php
public function testHandleProcessesPayloadCorrectly(): void
{
    // Arrange
    $mockService = $this->createMock({RelevantService}::class);
    $mockService->expects($this->once())
        ->method('processItem')
        ->willReturn($expectedResult);

    $handler = new {NombreJobHandler}($mockService);

    $payload = ['test' => 'data'];

    // Act
    $result = $handler->handle($payload);

    // Assert
    $this->assertEquals($expectedResult, $result);
}

Paso 4: Documentar Tipo de Job

Agregar a: docs/backend/background-jobs-handlers.md

markdown
### `{tipo_job}`

**Handler**: `{NombreJobHandler}`

**Payload**:
- `campo1` (tipo): Descripcion
- `campo2` (tipo): Descripcion

**Result**:
- `resultado1` (tipo): Descripcion
- `resultado2` (tipo): Descripcion

**Ejemplo**:
POST /backend/jobs/{tipo_job}
{
  "payload": {
    "campo1": "valor"
  }
}

Checklist de Implementacion

  • [ ] Crear clase handler que implemente JobHandlerInterface
  • [ ] Inyectar services necesarios via constructor
  • [ ] Implementar getType() retornando tipo unico
  • [ ] Implementar handle(array $payload, ?callable $onProgress = null): array
  • [ ] Validar payload con metodo privado
  • [ ] Delegar procesamiento a service existente (patron wrapper)
  • [ ] Acumular resultados y errores
  • [ ] Retornar array con resultado consolidado
  • [ ] Agregar al array job.handlers en container/shared-definitions.php
  • [ ] Agregar autowire() en container/shared-definitions.php
  • [ ] Crear unit test mockeando service
  • [ ] Documentar tipo de job con payload y result
  • [ ] Probar flujo end-to-end (dispatch → execute → notify)

◄ Anterior: API Endpoints | Índice | Siguiente: Multi-Tenant ►