Appearance
Arquitectura de Componentes
◄ Volver al índice | Siguiente: Base de Datos ►
Tabla de Contenidos
- Value Objects
- Repositorios (Data Access Layer)
- Servicios (Business Logic Layer)
- Handlers (Strategy Pattern)
- Controllers (HTTP Layer)
- CLI Workers
Value Objects
BackgroundJob
Ubicación: Core/ValueObjects/BackgroundJob.php
Namespace: App\Core\ValueObjects
Propósito: Representar un job en el sistema con todos sus metadatos
Propiedades:
| Propiedad | Tipo | Descripción |
|---|---|---|
id | int | ID único del job (null si no persistido) |
type | string | Tipo de job (ej: 'batch_invoicing') |
status | string | Estado: pending | running | completed | failed |
payload | array | Datos necesarios para ejecutar el job |
result | array|null | Resultado del job (null si no terminó) |
error | string|null | Mensaje de error (null si no falló) |
user_id | int | ID del usuario que creó el job |
schema | string | Schema PostgreSQL donde ejecutar (CRÍTICO) |
created_at | string | Timestamp de creación |
started_at | string|null | Timestamp de inicio de ejecución |
completed_at | string|null | Timestamp de finalización |
Métodos:
isPending(): bool- Verifica si está pendienteisRunning(): bool- Verifica si está ejecutándoseisCompleted(): bool- Verifica si terminó exitosamenteisFailed(): bool- Verifica si fallógetExecutionTime(): int|null- Duración en segundos
Notification
Ubicación: Core/ValueObjects/Notification.php
Namespace: App\Core\ValueObjects
Propósito: Representar notificación al usuario sobre resultado de job
Propiedades:
| Propiedad | Tipo | Descripción |
|---|---|---|
id | int | ID único de notificación |
user_id | int | Usuario destinatario |
type | string | Tipo: success | error | info |
title | string | Título breve |
message | string | Mensaje detallado |
metadata | array | Datos adicionales (ej: job_id, result) |
is_read | bool | Si fue leída |
created_at | string | Timestamp de creación |
read_at | string|null | Timestamp de lectura |
Métodos:
markAsRead(): void- Marca como leídaisRead(): bool- Verifica si fue leídagetJobId(): int|null- Extrae job_id de metadata
Repositorios (Data Access Layer)
JobRepository
Ubicación: Core/Repositories/JobRepository.php
Namespace: App\Core\Repositories
Responsabilidades:
- CRUD de jobs en tabla
background_jobs - Queries específicas: jobs por usuario, jobs por estado
- NO lógica de negocio (solo persistencia)
Métodos Principales:
create(BackgroundJob $job): int
Descripción: Insertar nuevo job en BD
SQL:
sql
INSERT INTO background_jobs (type, status, payload, user_id, schema, created_at)
VALUES (:type, :status, :payload, :user_id, :schema, NOW())
RETURNING idRetorna: ID del job creado
findById(int $id): BackgroundJob|null
Descripción: Obtener job por ID
SQL:
sql
SELECT * FROM background_jobs
WHERE id = :id
LIMIT 1Retorna: BackgroundJob o null si no existe
update(BackgroundJob $job): void
Descripción: Actualizar job existente (estado, resultado, error)
SQL:
sql
UPDATE background_jobs SET
status = :status,
result = :result,
error = :error,
started_at = :started_at,
completed_at = :completed_at
WHERE id = :idcountPendingByUser(int $userId): int
Descripción: Contar jobs pendientes de un usuario (DOS protection)
SQL:
sql
SELECT COUNT(*) FROM background_jobs
WHERE user_id = :user_id
AND status = 'pending'Retorna: Cantidad de jobs pendientes
findStaleJobs(int $minutesThreshold): array
Descripción: Encontrar jobs "stale" (running > X minutos, probablemente crashed)
SQL:
sql
SELECT * FROM background_jobs
WHERE status = 'running'
AND started_at < NOW() - INTERVAL ':minutes minutes'Retorna: Array de BackgroundJob
NotificationRepository
Ubicación: Core/Repositories/NotificationRepository.php
Namespace: App\Core\Repositories
Responsabilidades:
- CRUD de notificaciones en tabla
notifications - Queries: notificaciones por usuario, no leídas
Métodos Principales:
create(Notification $notification): int
Descripción: Insertar nueva notificación
SQL:
sql
INSERT INTO notifications (user_id, type, title, message, metadata, is_read, created_at)
VALUES (:user_id, :type, :title, :message, :metadata, FALSE, NOW())
RETURNING idRetorna: ID de notificación creada
findUnreadByUser(int $userId): array
Descripción: Obtener notificaciones no leídas de un usuario
SQL:
sql
SELECT * FROM notifications
WHERE user_id = :user_id
AND is_read = FALSE
ORDER BY created_at DESCRetorna: Array de Notification
markAsRead(int $id): void
Descripción: Marcar notificación como leída
SQL:
sql
UPDATE notifications SET
is_read = TRUE,
read_at = NOW()
WHERE id = :idServicios (Business Logic Layer)
JobDispatcher
Ubicación: Core/Services/JobDispatcher.php
Namespace: App\Core\Services
Responsabilidades:
- Validar límites de jobs pendientes (DOS protection)
- Crear job en BD
- Lanzar worker en background con
exec() - Retornar job ID al controller
Dependencias:
JobRepository- Para persistir jobsConnectionManager- Para acceso a DB- Configuración:
MAX_PENDING_JOBS_PER_USER(default: 10)
Métodos Públicos:
dispatch(string $type, array $payload, int $userId, string $schema): int
Descripción: Despachar nuevo job para ejecución asíncrona
Parámetros:
$type: Tipo de job (debe tener handler registrado)$payload: Datos necesarios para ejecutar el job$userId: Usuario que crea el job$schema: Schema PostgreSQL para ejecución (CRÍTICO multi-tenant)
Retorna: ID del job creado
Excepciones:
TooManyJobsException: Si usuario excede límite de jobs pendientesInvalidJobTypeException: Si tipo de job no tiene handler registrado
Flujo:
- Validar que tipo de job tenga handler registrado (consultar JobExecutor)
- Verificar límite:
countPendingByUser($userId) < MAX_PENDING_JOBS_PER_USER - Crear BackgroundJob con status='pending'
- Persistir en BD (JobRepository::create)
- Lanzar worker:
exec("php cli/background-worker.php {$jobId} > /dev/null 2>&1 &") - Retornar job ID
Nota Crítica: El exec() con & al final NO espera al proceso hijo (non-blocking)
JobExecutor
Ubicación: Core/Services/JobExecutor.php
Namespace: App\Core\Services
Responsabilidades:
- Registrar handlers disponibles (Strategy Pattern)
- Ejecutar job con el handler correspondiente
- Actualizar estado del job (running → completed/failed)
- Crear notificación al usuario con resultado
Dependencias:
JobRepository- Para actualizar estadoNotificationRepository- Para crear notificaciónConnectionManager- Para multi-tenant schema setup- Array de handlers registrados
Métodos Públicos:
registerHandler(JobHandlerInterface $handler): void
Descripción: Registrar nuevo handler para un tipo de job
Parámetros:
$handler: Instancia de handler que implementa JobHandlerInterface
Flujo:
- Obtener tipo con
$handler->getType() - Registrar en array interno:
$this->handlers[$type] = $handler
hasHandler(string $type): bool
Descripción: Verificar si existe handler para un tipo de job
Parámetros:
$type: Tipo de job
Retorna: true si existe handler registrado
execute(int $jobId): void
Descripción: Ejecutar job por ID completo (cargar, ejecutar, actualizar, notificar)
Parámetros:
$jobId: ID del job a ejecutar
Excepciones:
JobNotFoundException: Si job no existeNoHandlerException: Si tipo de job no tiene handler- Cualquier exception lanzada por el handler
Flujo:
- Cargar job desde BD (JobRepository::findById)
- Validar que job esté en status='pending'
- Actualizar status='running', started_at=NOW()
- Configurar schema:
ConnectionManager::setSearchPath($job->schema)(CRÍTICO) - Obtener handler para
$job->type - Ejecutar:
$result = $handler->handle($job->payload) - Si OK:
- Actualizar status='completed', result=$result, completed_at=NOW()
- Crear notificación tipo='success'
- Si Exception:
- Actualizar status='failed', error=$exception->getMessage(), completed_at=NOW()
- Crear notificación tipo='error'
Nota Crítica: El paso 4 (configurar schema) es CRÍTICO para multi-tenancy. Si se omite, el job ejecutará en el schema incorrecto.
NotificationService
Ubicación: Core/Services/NotificationService.php
Namespace: App\Core\Services
Responsabilidades:
- Crear notificaciones de diferentes tipos
- Consultar notificaciones no leídas
- Marcar notificaciones como leídas
Dependencias:
NotificationRepository- Para persistir notificaciones
Métodos Públicos:
createFromJobResult(BackgroundJob $job): void
Descripción: Crear notificación basada en resultado de job
Parámetros:
$job: Job completado o fallido
Flujo:
- Determinar tipo según status del job:
completed→ type='success'failed→ type='error'
- Generar título y mensaje apropiados
- Incluir metadata:
['job_id' => $job->id, 'job_type' => $job->type] - Persistir con NotificationRepository::create
getUnreadByUser(int $userId): array
Descripción: Obtener notificaciones no leídas de un usuario
Retorna: Array de Notification
markAsRead(int $notificationId): void
Descripción: Marcar notificación específica como leída
Handlers (Strategy Pattern)
JobHandlerInterface
Ubicación: Core/Interfaces/JobHandlerInterface.php
Namespace: App\Core\Interfaces
Propósito: Contrato que deben cumplir todos los handlers de jobs
Métodos:
php
interface JobHandlerInterface
{
/**
* Obtener tipo de job que maneja este handler
*/
public function getType(): string;
/**
* Ejecutar job con payload dado
*
* @param array $payload Datos necesarios para ejecutar
* @return array Resultado del job
* @throws Exception Si falla la ejecución
*/
public function handle(array $payload): array;
}BatchInvoicingJobHandler (Ejemplo)
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
]Dependencias:
FacturaService- Service existente de facturación (NO modificado)
Patrón Wrapper:
El handler NO modifica FacturaService. En su lugar:
- Recibe payload con datos consolidados
- Itera sobre los items a procesar
- Para cada item, reconstruye el request DTO que espera
FacturaService::insert() - Delega a
FacturaService::insert($dto)(método existente sin cambios) - Acumula resultados y errores
- Retorna consolidado
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)
Controllers (HTTP Layer)
JobController
Ubicación: Core/Controllers/JobController.php
Namespace: App\Core\Controllers
Responsabilidades:
- Despachar jobs (POST)
- Consultar estado de job (GET)
- Listar jobs del usuario (GET all)
Dependencias:
JobDispatcher- Para despachar jobsJobRepository- Para consultar jobsAuthService- Para obtener user_id del JWT
Métodos:
dispatch(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
Endpoint: POST /api/jobs/{type}
Path Params:
type: Tipo de job (ej: 'batch_invoicing')
Request Body:
json
{
"payload": {
"cliente_ids": [1, 2, 3],
"fecha": "2026-02-05"
}
}Response (202 Accepted):
json
{
"status": "accepted",
"job_id": 123,
"message": "Job creado, se ejecutará en segundo plano"
}Códigos de respuesta:
- 202 Accepted: Job creado exitosamente
- 400 Bad Request: Payload inválido
- 429 Too Many Requests: Usuario excede límite de jobs
- 422 Unprocessable Entity: Tipo de job no existe
getStatus(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
Endpoint: GET /api/jobs/{id}
Path Params:
id: ID del job
Response:
json
{
"status": "success",
"data": {
"id": 123,
"type": "batch_invoicing",
"status": "completed",
"result": {
"facturas_creadas": 5,
"monto_total": 5000.00
},
"created_at": "2026-02-05T10:00:00Z",
"completed_at": "2026-02-05T10:05:30Z",
"execution_time_seconds": 330
}
}Códigos de respuesta:
- 200 OK: Job encontrado
- 404 Not Found: Job no existe o no pertenece al usuario
JobStreamController (Fase 2)
Ubicación: Core/Controllers/JobStreamController.php
Namespace: App\Core\Controllers
Propósito: Endpoint SSE para recibir actualizaciones en tiempo real (Fase 2)
Endpoint: GET /api/jobs/{id}/stream
Response: Server-Sent Events (SSE)
Event types:
job_status: Actualización de estadojob_completed: Job terminado exitosamentejob_failed: Job falló
Implementación:
- Configurar headers SSE:
Content-Type: text/event-stream - Escuchar canal PostgreSQL NOTIFY:
job_updates_{$jobId} - Enviar eventos al cliente cuando llegan notificaciones
- Cerrar stream cuando job termina o cliente desconecta
Frontend (EventSource):
javascript
const eventSource = new EventSource(`/api/jobs/${jobId}/stream`);
eventSource.addEventListener('job_completed', (event) => {
const data = JSON.parse(event.data);
console.log('Job completado:', data);
eventSource.close();
});CLI Workers
background-worker.php
Ubicación: cli/background-worker.php
Propósito: Script CLI que ejecuta un job específico
Uso:
bash
php cli/background-worker.php {job_id}Flujo:
- Cargar
bootstrap-cli.php(sin HTTP) - Validar que se recibió job_id como argumento
- Obtener JobExecutor del DI container
- Ejecutar:
$executor->execute($jobId) - Exit code: 0 si OK, 1 si error
Ejecución en background:
bash
# Lanzado por JobDispatcher con exec()
php cli/background-worker.php 123 > /dev/null 2>&1 &Logging:
- Log a archivo:
/logs/background-jobs.log - Structured logging con context:
['job_id' => 123, 'type' => 'batch_invoicing']
bootstrap-cli.php
Ubicación: cli/bootstrap-cli.php
Propósito: Bootstrap del sistema sin HTTP (para CLI scripts)
Diferencias con bootstrap HTTP:
- NO carga Slim App
- NO carga routes
- SÍ carga DI container
- SÍ carga ConnectionManager
- SÍ carga configuración