O erro estrutural mais caro é deixar o objeto que o modelo devolve circular pelo sistema inteiro. Quando o retorno cru do provedor vira o argumento que atravessa serviço, entra na fila e é persistido, qualquer mudança na saída do modelo se propaga para dezenas de pontos ao mesmo tempo. O conserto exige tocar em tudo, e o rollback fica impossível porque já existe dado gravado no formato antigo e no novo.
A separação certa é a mesma de qualquer integração com terceiro: o formato do provedor é externo, o contrato do domínio é seu, e existe uma camada fina entre eles cuja única responsabilidade é traduzir. Essa camada é o único lugar do código que conhece o formato do modelo. Se o enum ganhar valor novo, o conserto acontece em um arquivo e não em quinze.
modelo adaptador dominio
| | |
|-- JSON valido --------->| |
| (forma do provedor) | |
| |-- valida estrito ------->| rejeita se
| | (chave extra = erro) | forma mudou
| | |
| |-- normaliza ------------>| "5" -> 5
| | (coercao explicita) | "URGENTE" -> urgent
| | |
| |-- mapeia desconhecido -->| enum novo ->
| | (nunca descarta) | bucket "unknown"
| | |
| |-- emite metrica -------->| taxa por campo
| | |
| |==== Intake (tipo do dominio) ====>
| | so este atravessa o sistema
Repare no detalhe do valor desconhecido. A tentação é lançar exceção quando o enum traz algo fora da lista, porque parece o comportamento estrito e correto. Na prática isso transforma uma degradação parcial em indisponibilidade: um valor novo em dois por cento das requisições derruba dois por cento do tráfego. Mapear para um bucket explícito de desconhecido, contar e seguir para o caminho de revisão humana preserva o serviço e ainda te dá o sinal.
// adapters/intake.js
// Unica fronteira que conhece o formato do modelo. Valida estrito,
// normaliza com coercao explicita e nunca deixa valor novo virar excecao.
const PRIORITIES = new Set(['low', 'normal', 'high']);
const ALLOWED_KEYS = new Set(['intent', 'priority', 'entities', 'confidence']);
export function toIntake(raw, { metrics }) {
// 1. Chave que o schema nao previa e sinal de deriva, nao de dado extra.
const unknownKeys = Object.keys(raw).filter((key) => !ALLOWED_KEYS.has(key));
if (unknownKeys.length > 0) {
metrics.increment('intake.unknown_key', { keys: unknownKeys.join(',') });
}
// 2. Coercao explicita: aceita "5" e 5, mas registra quando o tipo muda,
// porque tipo instavel e o primeiro sintoma de troca de modelo.
const confidence = coerceNumber(raw.confidence, { metrics, field: 'confidence' });
// 3. Enum desconhecido vira bucket, nunca excecao. Um valor novo em 2%
// das requisicoes nao pode derrubar 2% do trafego.
let priority = normalizePriority(raw.priority);
if (!PRIORITIES.has(priority)) {
metrics.increment('intake.unknown_enum', { field: 'priority', value: String(raw.priority) });
priority = 'unknown';
}
return {
intent: String(raw.intent ?? '').trim() || 'unclassified',
priority,
// Array ausente e array vazio sao coisas diferentes para o dominio:
// ausente significa "o modelo nao respondeu isso", vazio significa
// "o modelo respondeu que nao ha nada". Nao colapse os dois.
entities: Array.isArray(raw.entities) ? raw.entities.map(String) : null,
confidence: confidence ?? 0,
needsReview: priority === 'unknown' || confidence === null,
};
}
function coerceNumber(value, { metrics, field }) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
metrics.increment('intake.type_coercion', { field, from: 'string' });
return parsed;
}
}
return null;
}
function normalizePriority(value) {
return String(value ?? '').trim().toLowerCase();
}
Cada ponto de tolerância desse adaptador emite métrica. Essa é a diferença entre tolerar e ignorar: tolerar é aceitar o desvio e registrá-lo; ignorar é aceitar e ficar em silêncio. Um adaptador que absorve tudo sem contar nada esconde a deriva até o dia em que ela é grande demais.