Appearance
ADR-004: Wrapper Pattern (NO Refactoring)
Fecha: 2026-02-05 Estado: Aprobado Deciders: Architecture Team, Backend Team
Contexto y Problema
Queremos agregar ejecución asíncrona a operaciones existentes que funcionan sincrónicamente. Por ejemplo:
FacturaService::batchInvoice($data)existe y funciona bien- Queremos que TAMBIÉN se pueda ejecutar en background (async)
- NO queremos refactorizar
FacturaService(riesgo de bugs, testing masivo)
Pregunta: ¿Cómo agregar async sin modificar código existente?
Opciones Consideradas
Opción A: Wrapper Pattern (SELECCIONADA)
Descripción:
- Handler implementa
JobHandlerInterface - Handler recibe el service existente como dependencia
- Handler reconstruye el request DTO que espera el service
- Handler delega TODA la lógica al service existente
- Service NO sabe que está siendo ejecutado en background
Código:
php
class BatchInvoicingJobHandler implements JobHandlerInterface
{
public function __construct(
private FacturaService $service // Servicio existente SIN MODIFICAR
) {}
public function getType(): string
{
return 'batch_invoicing';
}
public function handle(array $payload): array
{
// 1. Reconstruir DTO que espera el service
$dto = new BatchInvoiceRequest(
cliente_ids: $payload['cliente_ids'],
fecha: $payload['fecha'],
// ...
);
// 2. Delegar al service (TODA la lógica está ahí)
$result = $this->service->batchInvoice($dto);
// 3. Retornar resultado
return $result;
}
}Pros:
- ✅ CERO modificaciones al service existente
- ✅ Service puede usarse sincrónicamente (controller) o asincrónicamente (handler)
- ✅ Rollback instantáneo (feature flag OFF = usa service directo)
- ✅ Testing simple (handler unit test mockea service)
- ✅ Bajo riesgo (NO tocamos código que funciona)
Contras:
- ❌ Duplicación leve (handler + controller construyen DTO similar)
- ❌ Handler debe conocer interface del service
Opción B: Refactorizar Service para Soportar Ambos Modos
Descripción:
- Service tiene flag
$asyncen constructor o método - Si
$async=true, service crea job en BD y retorna job_id - Si
$async=false, service ejecuta sincrónicamente (legacy)
Código:
php
class FacturaService
{
public function batchInvoice(BatchInvoiceRequest $data, bool $async = false): mixed
{
if ($async) {
// Crear job y retornar job_id
$jobId = $this->jobDispatcher->dispatch('batch_invoicing', $data);
return ['job_id' => $jobId];
}
// Ejecución síncrona (legacy)
return $this->executeBatchInvoice($data);
}
}Pros:
- ✅ Una sola interface (controller siempre llama al service)
- ✅ NO duplicación (lógica en un solo lugar)
Contras:
- ❌ Service ahora depende de background jobs (acoplamiento)
- ❌ Testing más complejo (mockear JobDispatcher)
- ❌ Refactoring de servicio existente (riesgo de bugs)
- ❌ Viola Single Responsibility (service hace dispatch Y ejecución)
Veredicto: ❌ Descartado (acoplamiento, refactoring riesgoso)
Opción C: Service Delega a Handler
Descripción:
- Service recibe
JobHandlerInterfacecomo dependencia - Service delega ejecución al handler (inversión)
Código:
php
class FacturaService
{
public function __construct(
private JobHandlerInterface $handler
) {}
public function batchInvoice(BatchInvoiceRequest $data): array
{
return $this->handler->handle($data->toArray());
}
}Pros:
- ✅ Service es thin wrapper (lógica en handler)
Contras:
- ❌ Inversión de dependencias (service depende de handler)
- ❌ Handler contendría lógica de negocio (violaría patrón)
- ❌ Service pierde razón de ser (se vuelve proxy)
Veredicto: ❌ Descartado (inversión incorrecta, viola arquitectura)
Decisión
Seleccionamos Opción A: Wrapper Pattern
Justificación:
- Patrón estándar para agregar features incrementales sin modificar código existente
- Bajo riesgo (código que funciona NO se toca)
- Feature flag permite rollback instantáneo
- Consistente con arquitectura existente (handlers son Strategy Pattern)
Consecuencias
Positivas
- ✅ CERO impacto en código legacy
- ✅ Feature flag permite rollout gradual por módulo
- ✅ Fácil testing (unit test handler con mock service)
- ✅ Service puede usarse en ambos contextos (sync/async)
Negativas
- ❌ Leve duplicación (handler + controller construyen DTO)
- ❌ Si service cambia interface, handler debe actualizarse
Mitigaciones
Mitigaciones:
- Duplicación: Mínima, solo construcción de DTO (5-10 líneas)
- Interface changes: Tests fallan si handler desincronizado (protección)
Implementación
Controller con feature flag:
php
public function batchInvoice(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$payload = $request->getParsedBody();
if ($this->config['background_jobs']['enabled']) {
// Flujo asíncrono (nuevo)
$jobId = $this->jobDispatcher->dispatch(
'batch_invoicing',
$payload,
$this->auth->getUserId(),
$request->getHeaderLine('X-Schema')
);
return $this->jsonResponse($response, [
'status' => 'accepted',
'job_id' => $jobId
], 202);
} else {
// Flujo síncrono (legacy - sin cambios)
$dto = new BatchInvoiceRequest($payload);
$result = $this->service->batchInvoice($dto);
return $this->jsonResponse($response, $result, 200);
}
}Handler (wrapper):
php
class BatchInvoicingJobHandler implements JobHandlerInterface
{
public function __construct(
private FacturaService $service // Inyectado, NO modificado
) {}
public function handle(array $payload): array
{
// Reconstruir DTO que espera el service
$dto = new BatchInvoiceRequest($payload);
// Delegar TODA la lógica
return $this->service->batchInvoice($dto);
}
}