Appearance
Handlers: Implementación de Nuevos Jobs
◄ Anterior: API Endpoints | Índice | Siguiente: Multi-Tenant ►
Tabla de Contenidos
- Interface JobHandlerInterface
- Ejemplo: BatchInvoicingJobHandler
- Patrón Wrapper
- Guía para Agregar Nuevos Handlers
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
BatchInvoicingOrchestratoriniciada - 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:
- NO modifica service existente:
FacturaServicequeda intacto - Reconstruye request DTO: Crea el mismo DTO que usaría el controller síncrono
- Delega lógica compleja: El service hace TODO el trabajo (validaciones, transacciones, etc.)
- Acumula resultados: Handler solo itera y consolida
- 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 resultsRegistro 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 constructorJobHandlerRegistry 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.handlersencontainer/shared-definitions.php - [ ] Agregar
autowire()encontainer/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 ►