Skip to content

Arquitectura de Componentes

◄ Volver al índice | Siguiente: Base de Datos ►


Tabla de Contenidos


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:

PropiedadTipoDescripción
idintID único del job (null si no persistido)
typestringTipo de job (ej: 'batch_invoicing')
statusstringEstado: pending | running | completed | failed
payloadarrayDatos necesarios para ejecutar el job
resultarray|nullResultado del job (null si no terminó)
errorstring|nullMensaje de error (null si no falló)
user_idintID del usuario que creó el job
schemastringSchema PostgreSQL donde ejecutar (CRÍTICO)
created_atstringTimestamp de creación
started_atstring|nullTimestamp de inicio de ejecución
completed_atstring|nullTimestamp de finalización

Métodos:

  • isPending(): bool - Verifica si está pendiente
  • isRunning(): bool - Verifica si está ejecutándose
  • isCompleted(): bool - Verifica si terminó exitosamente
  • isFailed(): 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:

PropiedadTipoDescripción
idintID único de notificación
user_idintUsuario destinatario
typestringTipo: success | error | info
titlestringTítulo breve
messagestringMensaje detallado
metadataarrayDatos adicionales (ej: job_id, result)
is_readboolSi fue leída
created_atstringTimestamp de creación
read_atstring|nullTimestamp de lectura

Métodos:

  • markAsRead(): void - Marca como leída
  • isRead(): bool - Verifica si fue leída
  • getJobId(): 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 id

Retorna: 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 1

Retorna: 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 = :id

countPendingByUser(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 id

Retorna: 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 DESC

Retorna: 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 = :id

Servicios (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 jobs
  • ConnectionManager - 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 pendientes
  • InvalidJobTypeException: Si tipo de job no tiene handler registrado

Flujo:

  1. Validar que tipo de job tenga handler registrado (consultar JobExecutor)
  2. Verificar límite: countPendingByUser($userId) < MAX_PENDING_JOBS_PER_USER
  3. Crear BackgroundJob con status='pending'
  4. Persistir en BD (JobRepository::create)
  5. Lanzar worker: exec("php cli/background-worker.php {$jobId} > /dev/null 2>&1 &")
  6. 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 estado
  • NotificationRepository - Para crear notificación
  • ConnectionManager - 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:

  1. Obtener tipo con $handler->getType()
  2. 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 existe
  • NoHandlerException: Si tipo de job no tiene handler
  • Cualquier exception lanzada por el handler

Flujo:

  1. Cargar job desde BD (JobRepository::findById)
  2. Validar que job esté en status='pending'
  3. Actualizar status='running', started_at=NOW()
  4. Configurar schema: ConnectionManager::setSearchPath($job->schema) (CRÍTICO)
  5. Obtener handler para $job->type
  6. Ejecutar: $result = $handler->handle($job->payload)
  7. Si OK:
    • Actualizar status='completed', result=$result, completed_at=NOW()
    • Crear notificación tipo='success'
  8. 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:

  1. Determinar tipo según status del job:
    • completed → type='success'
    • failed → type='error'
  2. Generar título y mensaje apropiados
  3. Incluir metadata: ['job_id' => $job->id, 'job_type' => $job->type]
  4. 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:

  1. Recibe payload con datos consolidados
  2. Itera sobre los items a procesar
  3. Para cada item, reconstruye el request DTO que espera FacturaService::insert()
  4. Delega a FacturaService::insert($dto) (método existente sin cambios)
  5. Acumula resultados y errores
  6. 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 jobs
  • JobRepository - Para consultar jobs
  • AuthService - 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 estado
  • job_completed: Job terminado exitosamente
  • job_failed: Job falló

Implementación:

  1. Configurar headers SSE: Content-Type: text/event-stream
  2. Escuchar canal PostgreSQL NOTIFY: job_updates_{$jobId}
  3. Enviar eventos al cliente cuando llegan notificaciones
  4. 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:

  1. Cargar bootstrap-cli.php (sin HTTP)
  2. Validar que se recibió job_id como argumento
  3. Obtener JobExecutor del DI container
  4. Ejecutar: $executor->execute($jobId)
  5. 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

◄ Volver al índice | Siguiente: Base de Datos ►