Así despacha un envío el cliente de esta API:

// client/src/modules/shipment/services/dispatch-shipment.ts import { instance } from "@modules/app/modules/http/domain/instance"; import { DispatchShipmentDTO } from "../dto/write/dispatch-shipment"; export async function dispatchShipment( props: DispatchShipmentDTO, ): Promise<boolean> { try { await instance.post("/shipments/dispatch", props); return true; } catch { return false; } } 

Y así se consume, en el componente del botón:

// client/src/modules/shipment/components/DispatchButton/hooks/useDispatchButton.tsx const dispatched = await dispatchShipment({ shipmentId, carrierId }); if (!dispatched) { toastError({ en: "The shipment could not be dispatched.", es: "No se pudo despachar el envío.", }); } 

Este código no es un error, y revisándolo por sí mismo no hay nada que arreglar. El catch sin parámetro ni siquiera es descuido: es la forma honesta de decir "esto puede fallar y no me voy a inventar por qué". El juicio sobre este código no cambia; cambia el criterio con el que se mide.

Visto con ese criterio, lo que este catch acopla tiene dos clases. La primera son tres puntos locales y reversibles: el texto del error vive en el componente, así que la misma frase se repite en cada pantalla que despacha envíos; el catch no distingue un 503 de un 400, de modo que un servidor caído y una regla de negocio violada producen el mismo aviso; y el idioma se resuelve donde se pinta, que es el único sitio que lo sabe y por ahora funciona.

La segunda clase es una sola entrada y es de otra naturaleza. El cliente recibió la razón exacta del fallo y la tiró. El servidor sabía que ese envío pesa 32 kg y que el transportista admite 30; lo escribió en la respuesta, llegó al catch y ahí se descartó. Quien pulsó el botón lee "no se pudo despachar el envío" y se queda sin saber que le sobran dos kilos: la información existió del lado del cliente y se perdió por la forma del código que la recibió.

Hay un caso en el que nada de esto tiene coste observable. Con un solo cliente, un solo idioma y ningún error del que el usuario pueda hacer algo, el catch de arriba es la respuesta correcta y lo que viene después es ceremonia.

Adónde va esto

El destino, en bruto y sin justificar todavía. Primero, una excepción de dominio en el servidor que lleva sus datos aparte del texto:

// server/src/modules/shipment/domain/exceptions/shipment.exception.ts export class ShipmentWeightExceededException extends AppHttpException { constructor( private readonly maxWeight: number, private readonly actual: number, ) { super(); } status(): HttpStatus { return HttpStatus.CONFLICT; } code(): string { return "shipment-weight-exceeded"; } content(): string { return "The shipment weight exceeds what the carrier accepts"; } params(): AppHttpErrorParam[] { return [ { name: "maxWeight", value: String(this.maxWeight) }, { name: "actual", value: String(this.actual) }, ]; } } 

Segundo, lo que sale por el cable, que es siempre la misma forma venga de donde venga el fallo:

{ "time": "2026-10-23T09:14:02.511Z", "path": "/shipments/dispatch", "status": 409, "error": { "message": "The shipment weight exceeds what the carrier accepts", "status": 409, "code": "shipment-weight-exceeded", "params": [ { "name": "maxWeight", "value": "30" }, { "name": "actual", "value": "32" } ] } } 

Y tercero, el catálogo del cliente, que reconoce ese código y redacta con esos datos: "Este envío pesa 32 kg y el transportista admite 30." La frase no viaja; nace aquí, en el único sitio que sabe en qué pantalla va a aparecer y en qué idioma lee quien la va a leer. Lo que cuesta llegar ahí son dos catálogos a mano, uno en cada repositorio, que nada obliga a mantener sincronizados. Y un detalle que parece menor y decide el diseño: hay códigos que el cliente necesita redactar de dos maneras según qué operación falló.

Tres columnas: servidor, lo que viaja y cliente. A la izquierda, la excepción de dominio ShipmentWeightExceededException con su código shipment-weight-exceeded y dos parámetros con nombre, maxWeight 30 y actual 32. En el centro, el JSON que viaja en el orden del cable: message, atenuado y etiquetado «al log y de respaldo», status 409, code y params con los mismos dos valores. A la derecha, el catálogo de despachar con cuatro códigos y default(), y la entrada shipment-weight-exceeded resaltada; debajo, la única frase que ve el usuario: «Este envío pesa 32 kg y el transportista admite 30». El 30 y el 32 van en verde en los tres bloques, para que se vea que son el mismo dato.

Los dos números aparecen en los tres bloques; la frase, sólo en el último.

Los tres costes

1. Un código que no es estable rompe clientes que ya no controlas

Renombrar shipment-weight-exceeded no es un refactor. En cuanto ese identificador sale de tu repositorio y alguien ramifica sobre él, pasa a ser parte de la API tanto como el nombre de un campo, y el compilador deja de ayudarte: las referencias que habría que actualizar están en otro código base.

El segundo orden es que el coste no se paga al renombrar, sino meses después y en otro sitio. Una app compilada hace medio año espera el código viejo, el servidor manda el nuevo y el usuario ve el mensaje genérico. Nadie se enteró, porque nada falló.

2. Un message redactado para el usuario acaba también en el log, y las dos frases quieren lo contrario

El log quiere el valor que falló, el envío y el transportista; la pantalla quiere saber qué hacer ahora. Con un solo campo gana quien escribió el throw, y una de las dos lecturas queda mal servida siempre.

Al traducir se agrava, y las REST API Guidelines de Microsoft lo dicen con una razón que no se me había ocurrido: "services SHOULD NOT localize "message" for the end user, because doing so MAY make the value unreadable to the app developer who may be logging the value, as well as make the value less searchable on the Internet". El aviso que un ingeniero busca a las tres de la mañana aparece entonces en cinco idiomas, y deja de poder buscarse en internet.

3. Los parámetros sin forma acordada se convierten en interpolación en el servidor

En cuanto la frase lleva un número dentro alguien tiene que meterlo, y si el contrato no tiene sitio para los datos el único posible es la frase. Entonces el servidor ya no informa del fallo: redacta una línea de interfaz.

Lo peor llega después: el dato se vuelve ilegible justo para quien lo necesitaba. Un cliente que quiera el límite en un tooltip tiene que sacar el 30 de una cadena con una expresión regular, y esa expresión se rompe la próxima vez que alguien mejore la redacción —un cambio que nadie considera un cambio de contrato. Que esto pasa aunque los datos existan separados lo demuestra nestjs-i18n sin pretenderlo: el catálogo de su guía tiene los parámetros nombrados —"{property} with value: \"{value}\" needs to be at least {constraints.0}"— y lo que cruza el cable es "min with value: \"1\" needs to be at least 5".

La tesis

La especificación de HTTP ya resolvió esta discusión para su propia frase, y la resolvió contra el texto. El reason-phrase existe, dice RFC 9112 al definir la línea de estado, "for the sole purpose of providing a textual description associated with the numeric status code" — y acto seguido manda ignorarlo:

"A client SHOULD ignore the reason-phrase content because it is not a reliable channel for information (it might be translated for a given locale, overwritten by intermediaries, or discarded when the message is forwarded via other versions of HTTP)."

El protocolo manda ignorar el texto legible y la primera razón que da es que puede venir traducido. Lo que sigue es ese criterio aplicado al cuerpo del error en lugar de a la línea de estado.

Una excepción que cruza la frontera de la API es un identificador estable con datos, no una frase. El servidor nombra lo que falló y adjunta lo que hace falta para contarlo; redacta quien sabe en qué pantalla va a aparecer y en qué idioma lee quien lo va a leer.

Eso determina tres cosas al escribir código. El code identifica y el status clasifica el transporte: decide la política —reautenticar, reintentar, rendirse— sin reconocer nada, porque los tres fallos del transportista comparten un 502. Los datos viajan con nombre y fuera del texto, porque el único formato que le sirve al cliente es el que no hay que parsear. Y el message va al log y al cliente que no conozca el código.

La objeción más seria a esto la formuló Jørn Wildt en 2015, y va contra el mecanismo y no contra la conclusión: "if you depend on clients knowing all the dynamic message formats […] then you couple clients and servers more tightly together". Dicho de otro modo: cada parámetro que añades es un detalle más del que el cliente depende. Se responde con una regla, no con una preferencia: los parámetros son aditivos y las claves que ya se publicaron no se retiran nunca, aunque se queden vacías —"all keys that have been included must continue to be included", escribió AIP-193 años después—. Con esa regla, añadir un parámetro no rompe a nadie.

La implementación, pieza por pieza

El contrato, y por qué los parámetros son una lista de pares

La excepción de dominio es una clase abstracta con cuatro métodos que rellena cada error —el cuarto es el que no suele estar— y un quinto, response(), que los junta en la forma del contrato. El archivo completo:

// server/src/modules/app/domain/exception/app-http.exception.ts import { HttpStatus } from "@nestjs/common"; export type AppHttpErrorParam = { name: string; value: string; }; export abstract class AppHttpException extends Error { abstract status(): HttpStatus; abstract code(): string; abstract content(): string; params(): AppHttpErrorParam[] { return []; } response() { return { message: this.content(), status: this.status(), code: this.code(), params: this.params(), }; } } 

params() devuelve una lista de pares con nombre y no un objeto libre porque ese mismo array se escribe en el log estructurado y se consulta por nombre en el cliente. Un objeto libre invita a mandar media entidad —el envío entero, el transportista con sus tarifas— y lo que cruza una frontera no debería crecer por comodidad. El coste se nota al escribirlo: cada error es una clase.

Conviene decir qué parte de esto trae ya el framework, porque no es poca. Desde la versión 12, NestJS acepta un errorCode en las opciones de la excepción y lo serializa en el cuerpo, y su documentación aclara para quién es: "unlike cause, which is intended for logging and is never serialized, errorCode is part of the response body and is meant to be consumed by clients". El hueco del identificador ya está hecho, entonces. Falta dónde poner los datos del fallo: las únicas opciones que acompañan al errorCode son cause y description. De ahí sale el contrato de arriba.

Una excepción concreta, y el sitio donde se lanza

El caso mínimo son tres métodos y ningún dato, porque no hay nada que contar más allá del hecho:

// server/src/modules/shipment/domain/exceptions/shipment.exception.ts export class ShipmentAlreadyDispatchedException extends AppHttpException { status(): HttpStatus { return HttpStatus.CONFLICT; } code(): string { return "shipment-already-dispatched"; } content(): string { return "The shipment was already dispatched"; } } 

El del peso añade el cuarto método, el de los params, y está al principio, en Adónde va esto. Las dos conviven en el mismo caso de uso y son los dos tipos de fallo: uno que no lleva nada que contar y otro que sí. La clase es la mitad del cambio; la otra mitad está donde se lanza. Antes, con las excepciones del framework y reducido a las dos comprobaciones:

// server/src/modules/shipment/application/use-cases/dispatch-shipment.uc.ts if (shipment.dispatchedAt !== null) { throw new ConflictException("The shipment was already dispatched"); } if (shipment.weight > carrier.maxWeight) { throw new ConflictException( `The shipment weighs ${shipment.weight}kg and the carrier accepts ${carrier.maxWeight}kg`, ); } 

Después, las mismas comprobaciones en el mismo caso de uso:

// server/src/modules/shipment/application/use-cases/dispatch-shipment.uc.ts if (shipment.dispatchedAt !== null) { throw new ShipmentAlreadyDispatchedException(); } if (shipment.weight > carrier.maxWeight) { throw new ShipmentWeightExceededException(carrier.maxWeight, shipment.weight); } 

Tres cosas cambian de sitio. La redacción se va del caso de uso, que ahora nombra los dos fallos y entrega los datos del segundo sin decidir cómo se cuentan. El 409 deja de ser una decisión del punto donde se lanza y pasa a ser una propiedad del error, declarada una vez en su clase, así que dos rutas que violen la misma regla no pueden contestar con status distinto. Y desaparece la interpolación, que es donde el dato quedaba pegado al texto: esa cadena es la que obliga al cliente a sacar el 30 con una expresión regular, como se vio en Los tres costes. A cambio hay un archivo más por error.

El filtro global, y el fallo que no estaba previsto

El filtro traduce cualquier cosa que pase por ahí a la forma del contrato, y manda el message al log como campo, no dentro del texto. Reducido a las dos ramas que importan:

// server/src/modules/app/infrastructure/nest/filters/http-error.filter.ts @Catch() export class AppExceptionFilter implements ExceptionFilter { constructor(private readonly logger: PinoLogger) {} catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const request = ctx.getRequest(); const error = this.build(exception); this.logger.warn({ message: error.message, context: AppExceptionFilter.name, data: { status: error.status, code: error.code, path: request.url, params: error.params, }, }); ctx.getResponse().status(error.status).json({ time: new Date().toISOString(), path: request.url, status: error.status, error: error, }); } private build(exception: unknown): AppErrorResponse { if (exception instanceof AppHttpException) { return exception.response(); } if (exception instanceof HttpException) { return this.fromHttpException(exception); } return { message: "There was an unexpected server error", status: HttpStatus.INTERNAL_SERVER_ERROR, code: "internal-server-error", params: [], }; } } 

La rama del medio es la que hace trabajar al contrato: las excepciones del propio framework —un guard, el ValidationPipe, una ruta que no existe— no tienen código de dominio, así que fromHttpException deriva uno del status y un 401 sale como unauthorized. Es un código de segunda, pero basta para que el cliente no tenga que distinguir de dónde vino el fallo.

La frontera del cliente

Nada de dominio del cliente ve nunca un error de axios. El interceptor normaliza antes:

// client/src/modules/app/modules/http/utils/handle-response-error.ts export function handleResponseError(error: unknown): AppHttpError { if (!axios.isAxiosError(error)) { return unknownError(); } const payload = (error.response?.data as { error?: Partial<AppHttpError> }) ?.error; return { message: payload?.message ?? "", status: error.response?.status ?? 500, code: payload?.code ?? "", params: Array.isArray(payload?.params) ? payload.params : [], }; } 

El message del servidor se recoge y se guarda aunque no se vaya a pintar. Es el respaldo del que habla Matices honestos.

El espejo, los datos y el catálogo

La excepción del cliente es el espejo de la del servidor con dos diferencias que son el artículo entero: reconoce por code y nada más, y su mensaje es un valor con sus dos idiomas dentro, no una clave contra un diccionario global.

// client/src/modules/app/domain/exceptions/app-http-exception.ts export abstract class AppHttpException extends AppError { private _params: AppHttpErrorParam[] = []; abstract code(): string; abstract message(): TranslationConfig; setParams(value: AppHttpErrorParam[]) { this._params = value; } matches(error: AppHttpError): boolean { return this.code() === error.code; } searchParam(key: string): string | null { return this._params.find((p) => p.name === key)?.value ?? null; } } 

Que matches compare sólo el código es una decisión, y la contraria también se defiende: repetir el status en el espejo es una afirmación comprobable sobre el contrato. El problema es qué pasa cuando falla. Si el servidor cambia el status de un error —un 409 que alguien pasa a 422—, el espejo deja de coincidir, el error cae en el mensaje genérico y no falla nada, ni al compilar ni en los tests.

Y así se escribe un error con datos:

// client/src/modules/shipment/domain/exceptions/shipment-exceptions.ts export class ShipmentWeightExceededHttpException extends AppHttpException { code(): string { return "shipment-weight-exceeded"; } message(): TranslationConfig { const max = this.searchParam("maxWeight"); const actual = this.searchParam("actual"); return { en: `This shipment weighs ${actual} kg and the carrier accepts ${max}.`, es: `Este envío pesa ${actual} kg y el transportista admite ${max}.`, }; } } 

El catálogo de cada operación es una lista declarativa de lo que puede fallar, y el resolver la recorre:

// client/src/modules/app/domain/exceptions/app-http-error-resolver.ts export abstract class AppHttpExceptionResolver { protected abstract errors(): AppHttpException[]; protected abstract default(): AppError; execute(error: AppHttpError): AppError[] { const found = this.errors().find((e) => e.matches(error)); if (!found) { return [this.default()]; } found.setParams(error.params); return [found]; } } 

Los parámetros entran por un setter y no como argumento de message(), y esa forma tiene tres razones. El catálogo es una lista de instancias porque así se compone por extensión: un conjunto común de errores que cualquier ruta puede devolver se añade con un spread. Reconocer y redactar viven en el mismo objeto, así que no hay dos artefactos que puedan desincronizarse. Y los parámetros llegan después de reconocer, porque el catálogo se declara sin datos y los datos aparecen en el fallo concreto. setParams es el momento exacto en que el error del transporte entrega su carga a quien sabe redactarla. Y como el espejo ya es el error que pinta la interfaz, el resolver devuelve esa misma instancia, sin adaptador en medio; por eso cada catálogo crea con new las entradas que llevan datos: compartidas, leerían los del último fallo.

Con esto, el inventario del gancho queda así. El texto ya no vive en el componente, sino en el catálogo del módulo. El catch distingue un 503 de un 400 porque lo que recibe es un código, no una excepción opaca. El idioma se sigue resolviendo donde se pinta, que era correcto y no cambia. Y la entrada que era de otra naturaleza —la razón exacta que llegó y se tiró— deja de existir: los 32 kg y el límite de 30 llegan con nombre y se usan para redactar la única frase que quien pulsó el botón necesitaba leer.

Un resolver por operación

Cada operación del cliente declara su propio catálogo: los fallos que sabe explicar y la frase que da cuando no reconoce el código. Este es el de imprimir una etiqueta:

// client/src/modules/shipment/services/print-shipment-label.ts class PrintLabelExceptionResolver extends AppHttpExceptionResolver { protected errors(): AppHttpException[] { return [ new TooManyLabelRequestsHttpException(), new ShipmentAlreadyDispatchedHttpException(), ...CARRIER_HTTP_EXCEPTIONS, ]; } protected default(): AppError { return new PrintLabelError(); } } 

errors() lista sólo los códigos que esta operación puede recibir y merece la pena explicar; lo que no está ahí cae en default(), que es la frase de la operación —"no se pudo imprimir la etiqueta"— y no una genérica de toda la aplicación. Y el orden importa: find se queda con la primera coincidencia, que es el primer riesgo de Matices honestos.

El servicio lo usa en un único sitio, su catch, y desde ahí el componente ya no recibe un error de red: recibe errores que saben decir su frase en los dos idiomas. Lo que entrega a quien lo llama tiene su propio tipo:

// client/src/modules/app/modules/http/domain/http-response-result.ts import { AppError } from "@modules/app/domain/exceptions/app-error"; export type HttpResponseResult<T = null> = { data: T; errors: null } | { errors: AppError[]; data: null }; 

El servicio no lanza: devuelve. La unión tiene dos miembros y cada uno anula el campo del otro, así que para llegar a data hay que descartar antes errors, y de eso se encarga el compilador: el componente no tiene dónde saltarse el fallo, ni necesita otro try alrededor. El plural de errors es la lista que devuelve el resolver —aquí siempre con un elemento— y deja sitio para el fallo que traiga varios: una validación que se queja de tres campos entra en la misma forma sin tocar ninguna firma.

// client/src/modules/shipment/services/print-shipment-label.ts export async function printShipmentLabel({ shipmentId, carrierId, }: PrintShipmentLabelDTO): PromiseSource:https://dev.to/hgomezrobaina/excepciones-de-dominio-entre-nestjs-y-react-un-identificador-con-datos-no-una-frase-1nlm

Related posts