Skip to content

Plataforma técnica · Orchestrator

Base Service

Orchestrator Common Transactions

BaseService es la clase abstracta base que heredan casi todos los servicios de dominio del Orchestrator. Encapsula seis preocupaciones cross-cutting que aparecerían replicadas en cada servicio si no existiera: acceso a pool por tenant, transacciones con outbox para eventos, cache, helpers de validación, logging contextual y result builders consistentes.

Todos los métodos de servicio que heredan BaseService reciben un ServiceContext:

CampoTipoUso
tenantDbstringNombre de la base del tenant (e.g. nostromo_60004317).
userIdstringUUID del usuario que originó la request.
requestIdstring?UUID por petición para correlación de logs y eventos.

El handler HTTP construye el contexto en routes/.../*.ts desde req.user y req.id:

Construcción típica del contexto
const ctx: ServiceContext = {
tenantDb: await getDatabaseNameForUser(req.user, req),
userId: req.user!.userId,
requestId: req.id,
};
const result = await capitalService.create(ctx, dto);
Método protectedDevuelveCuándo usarlo
this.getPool(tenantDb)Pool del tenant (cacheado).Lecturas/escrituras a la base del tenant.
this.getCommonPool()Pool de nostromo_common (parámetros).Leer indicadores, AFP, AFC, etc.

Ambos delegan en lib/db.ts (getTenantPool, commonPool).

Patrón estándar para operaciones que mutan estado. El callback recibe un PoolClient ya en BEGIN y un outbox transaccional:

Patrón de uso
return this.withTransaction(ctx, async (client, outbox) => {
const entry = await CapitalRepository.create(client, data, ctx.userId);
outbox.queue('capital:movimiento', {
capitalId: entry.id,
tipoMovimiento: mapTipo(entry.tipo_capital),
monto: Number(entry.monto),
});
return this.success(entry);
});

Comportamiento:

FaseAcción
Antes del callbackBEGIN.
Si callback throwROLLBACK. logError(ctx, ...). El throw se propaga. Outbox descartado.
Si callback resuelveCOMMIT. Luego flushOutbox(ctx, outbox). Retorna el valor.
Siempreclient.release().

Implementación in-memory por TX:

sequenceDiagram
  autonumber
  participant H as Handler
  participant S as Service
  participant DB as PostgreSQL
  participant O as Outbox (memoria TX)
  participant B as DomainEventBus

  H->>S: method(ctx, dto)
  S->>DB: BEGIN
  S->>DB: INSERT/UPDATE...
  S->>O: outbox.queue('event:x', payload)
  alt success
    S->>DB: COMMIT
    S->>O: drain()
    O-->>S: [PendingEvent...]
    loop por cada evento
      S->>B: emit(event, payload + ctx)
    end
    S-->>H: result
  else throw
    S->>DB: ROLLBACK
    Note over O: descartado<br/>(eventos NO emitidos)
    S--xH: error
  end

Por qué outbox y no emit directo: garantiza que si la TX rollback (e.g. unique violation, validación tardía), los listeners no ven un evento “fantasma” que reaccione a un cambio que nunca persistió. El outbox vive solo en memoria de la TX — no hay tabla outbox en disco.

Auto-enriquecimiento del payload: al hacer flushOutbox, el evento se enriquece con tenantDb, userId, requestId del ServiceContext antes de llegar al bus. Los servicios solo pasan el payload de negocio.

Errores en listeners no rompen la TX: los listeners corren post-COMMIT. Si uno lanza, se loguea ([BaseService] flushOutbox: error emitting "X") pero el método retorna ok. La TX ya está cerrada.

Catálogo de eventos disponibles en DomainEventBus.

withSerializableTransaction(ctx, callback)

Section titled “withSerializableTransaction(ctx, callback)”

Variante con ISOLATION LEVEL SERIALIZABLE para operaciones críticas (cierres contables, contadores anti-race). Retry automático en serialization failure (40001) — re-llama a sí misma recursivamente. No tiene cap de profundidad — si la contención persiste, puede stack-overflow. En la práctica el conflicto se resuelve en 1-2 retries.

Tres helpers delgados sobre lib/cache.ts:

MétodoBacking cacheUso
this.cached(key, fetcher, ttlSeconds)apiCacheCacheo general.
this.invalidateCache(pattern)apiCacheBorrado por wildcard pattern.
this.cachedConfig(key, fetcher)configCacheParámetros que rara vez cambian (sin TTL fijo).

Servicios con cache propia (como SystemConfigService) usan estructuras dedicadas — estos helpers son para casos genéricos.

HelperLanzaPara
this.assert(condition, message)ValidationError(message)Aserciones inline.
this.assertExists(entity, name, id?)NotFoundError(name, id)Después de findById/findByCode.
this.validateRequired<T>(data, fields)ValidationError([{field, message}])DTO completo — typo-safe (keyof T).

validateRequired filtra undefined, null y "". No detecta 0 como faltante — usar assert explícita para campos numéricos requeridos.

Tres niveles con prefijo del nombre del servicio y propagación de ctx:

MétodoOutput
this.log(ctx, message, data?)[ServiceName] message { requestId, userId, tenant, ...data }
this.logWarn(ctx, message, data?)⚠️ [ServiceName] message { ... }
this.logError(ctx, message, error)🔴 [ServiceName] message { error, stack, ... }

Salida va a console.log/warn/error — el runtime (Docker, systemd) la captura. No hay sink JSON estructurado.

Forma estándar de respuesta para handlers que retornan ServiceResult<T>:

interface ServiceResult<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
statusCode?: number;
}
BuilderRetorna
this.success(data, message?){ success: true, data, message, statusCode: 200 }
this.failure(error){ success: false, error, statusCode: 500 }
this.badRequest(message){ success: false, error: message, statusCode: 400 }
this.notFound(resource, id?){ success: false, error: '<resource> with ID <id> not found', statusCode: 404 }

En la práctica casi todo el código throw-ea NotFoundError/ValidationError y deja que el errorHandler global responda — notFound/badRequest son legacy de pre-error-handler. success sí se usa universalmente.

isPgError(err): err is PgError — narrowing type guard para errores de node-postgres. Expone code, detail, constraint, table, schema con tipo. Útil para hooks tipo:

catch (e) {
if (isPgError(e) && e.code === '23505') {
throw new ValidationError(`Duplicado en ${e.constraint}`);
}
throw e;
}

Casi todos los services del Orchestrator extienden BaseService directamente:

  • domain/payroll/PayrollService (y la mayoría de remuneraciones)
  • domain/activo-fijo/*Service
  • domain/capital/CapitalService
  • domain/chart-of-accounts/ChartOfAccountsService
  • domain/cicloContable/CicloContableService
  • domain/company/CompanyService
  • domain/configContable/ConfigContableService
  • domain/system-config/SystemConfigService (con cache propia adicional)
  • domain/legal-representatives/LegalRepService
  • … y muchos más.

Para servicios que prefieren métodos estáticos (e.g. PayrollService legacy), ServiceUtils expone funciones equivalentes sin la maquinaria de outbox:

FunciónEquivalente
ServiceUtils.withTransaction(...)BaseService.withTransaction sin outbox.
ServiceUtils.batchProcess(...)Procesa array con batchSize, onProgress, stopOnError.
ServiceUtils.assert(...)Como BaseService.assert.
ServiceUtils.assertExists(...)Como BaseService.assertExists.

Si necesitas outbox/eventos, usa la clase. Si solo necesitas TX y helpers, ServiceUtils evita instanciar.

BaseRepository es una clase opcional para repositories con tableName y schema. Ofrece query, queryOne, exists, count. Casi nadie la extiende — el patrón vigente es repositories estáticos envueltos con wrapStaticRepository (ver RepositoryDecorators).

Vive en el mismo archivo BaseService.ts por proximidad histórica, no por dependencia.