DEV Community

Cover image for Domain exceptions between NestJS and React: an identifier with data, not a sentence
Hector Angel Gomez Robaina
Hector Angel Gomez Robaina

Posted on Edited on

Domain exceptions between NestJS and React: an identifier with data, not a sentence

This is how the client of this API dispatches a shipment:

// 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

And this is how it is consumed, in the button component:

// 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.",
  });
}
Enter fullscreen mode Exit fullscreen mode

This code is not a bug, and reviewed on its own there is nothing to fix. The parameterless catch is not even carelessness: it is the honest way of saying "this can fail and I am not going to make up why". The verdict on this code does not change; the yardstick it is measured with does.

Measured that way, what this catch couples falls into two kinds. The first is three local, reversible points: the error text lives in the component, so the same sentence is repeated on every screen that dispatches shipments; the catch does not tell a 503 from a 400, so a server that is down and a broken business rule produce the same notice; and the language is resolved where it is rendered, which is the only place that knows it and works for now.

The second kind is a single entry, and of a different nature. The client received the exact reason for the failure and threw it away. The server knew that this shipment weighs 32 kg and that the carrier accepts 30; it wrote that in the response, it reached the catch, and there it was discarded. Whoever pressed the button reads "the shipment could not be dispatched" and never learns that they are two kilos over: the information existed on the client side and was lost because of the shape of the code that received it.

There is one case where none of this has an observable cost. With a single client, a single language and no error the user can do anything about, the catch above is the right answer and everything that follows is ceremony.

Where this is going

The destination, raw and not yet justified. First, a domain exception on the server that carries its data apart from the text:

// 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) },
    ];
  }
}
Enter fullscreen mode Exit fullscreen mode

Second, what goes over the wire, which always has the same shape wherever the failure comes from:

{
  "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" }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

And third, the client's catalogue, which recognises that code and writes the sentence with that data: "This shipment weighs 32 kg and the carrier accepts 30." The sentence does not travel; it is born here, in the only place that knows which screen it will appear on and which language its reader reads. Getting there costs two hand-kept catalogues, one in each repository, that nothing forces to stay in sync. And a detail that looks minor and decides the design: some codes need to be worded two ways by the client depending on which operation failed.

Three columns: server, what travels and client. On the left, the domain exception ShipmentWeightExceededException with its code shipment-weight-exceeded and two named parameters, maxWeight 30 and actual 32. In the middle, the JSON that travels in wire order: message, dimmed and labelled "for the log and as fallback", status 409, code and params with the same two values. On the right, the dispatch catalogue with four codes and default(), with the shipment-weight-exceeded entry highlighted; below it, the only sentence the user sees: "This shipment weighs 32 kg and the carrier accepts 30". The 30 and the 32 are green in all three blocks, to show they are the same data.

The two numbers appear in all three blocks; the sentence, only in the last one.

The three costs

1. A code that is not stable breaks clients you no longer control

Renaming shipment-weight-exceeded is not a refactor. As soon as that identifier leaves your repository and someone branches on it, it becomes as much a part of the API as a field name, and the compiler stops helping you: the references that would need updating live in another codebase.

Worse, the cost is not paid at rename time, but months later and somewhere else. An app built six months ago expects the old code, the server sends the new one and the user sees the generic message. Nobody noticed, because nothing failed.

2. A message written for the user also ends up in the log, and the two readers want opposite things

The log wants the value that failed, the shipment and the carrier; the screen wants to know what to do now. With a single field, whoever wrote the throw wins, and one of the two readings is always badly served.

Translation makes it worse, and Microsoft's REST API Guidelines say so with a reason I had not thought of: "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". The warning an engineer searches for at three in the morning then shows up in five languages, and can no longer be searched for online.

3. Parameters with no agreed shape turn into interpolation on the server

The moment the sentence carries a number, someone has to put it there, and if the contract has no slot for data the only one left is the sentence. At that point the server no longer reports the failure: it writes a line of UI.

The worst part comes later: the data becomes unreadable precisely for whoever needed it. A client that wants the limit in a tooltip has to pull the 30 out of a string with a regular expression, and that expression breaks the next time someone improves the wording, a change nobody considers a contract change. nestjs-i18n shows, without meaning to, that this happens even when the data exists separately: the catalogue in its guide has named parameters ("{property} with value: \"{value}\" needs to be at least {constraints.0}") and what crosses the wire is "min with value: \"1\" needs to be at least 5".

The thesis

The HTTP specification already settled this argument for its own sentence, and it settled it against the text. The reason-phrase exists, says RFC 9112 when defining the status line, "for the sole purpose of providing a textual description associated with the numeric status code", and right after that it says to ignore it:

"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)."

The protocol says to ignore the human-readable text, and the first reason it gives is that it may arrive translated. What follows is that criterion applied to the error body instead of the status line.

An exception that crosses the API boundary is a stable identifier with data, not a sentence. The server names what failed and attaches what is needed to tell it; the wording belongs to whoever knows which screen it will appear on and which language its reader reads.

That settles three things when writing code. The code identifies, and the status classifies the transport: it decides the policy (re-authenticate, retry, give up) without recognising anything, because the three carrier failures share a 502. The data travels named and outside the text, because the only format that serves the client is the one it does not have to parse. And the message goes to the log and to any client that does not know the code.

The most serious objection to this was raised by Jørn Wildt in 2015, and it targets the mechanism rather than the conclusion: "if you depend on clients knowing all the dynamic message formats […] then you couple clients and servers more tightly together". Put differently: every parameter you add is one more detail the client depends on. The answer is a rule, not a preference: parameters are additive, and keys that have been published are never withdrawn, even if they end up empty ("all keys that have been included must continue to be included", AIP-193 wrote years later). With that rule, adding a parameter breaks nobody.

The implementation, piece by piece

The contract, and why the parameters are a list of pairs

The domain exception is an abstract class with four methods that each error fills in (the fourth is the one that is usually missing) and a fifth, response(), that puts them together in the shape of the contract. The whole file:

// 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(),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

params() returns a list of named pairs rather than a free-form object because that same array is written to the structured log and looked up by name on the client. A free-form object invites sending half an entity (the whole shipment, the carrier with its rates), and what crosses a boundary should not grow for convenience. The cost shows when writing it: every error is a class.

Part of this the framework already provides, and it is not a small part. Since version 12, NestJS accepts an errorCode in the exception options and serialises it into the body, and its documentation is clear about who it is for: "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". So the slot for the identifier already exists. What is missing is somewhere to put the failure's data: the only options that come with errorCode are cause and description. The contract above comes from that gap.

A concrete exception, and the place where it is thrown

The minimal case is three methods and no data, because there is nothing to tell beyond the fact itself:

// 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";
  }
}
Enter fullscreen mode Exit fullscreen mode

The weight one adds the fourth method, the params one, and it is at the start, in Where this is going. Both live in the same use case and they are the two kinds of failure: one with nothing to tell and one with something. The class is half of the change; the other half is where it is thrown. Before, with the framework's exceptions and reduced to the two checks:

// 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`,
  );
}
Enter fullscreen mode Exit fullscreen mode

After, the same checks in the same use case:

// 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);
}
Enter fullscreen mode Exit fullscreen mode

Three things move. The wording leaves the use case, which now names the two failures and hands over the data of the second without deciding how it is told. The 409 stops being a decision of the place where it is thrown and becomes a property of the error, declared once in its class, so two routes that break the same rule cannot answer with different statuses. And the interpolation disappears, which is where the data got glued to the text: that string is what forces the client to extract the 30 with a regular expression, as seen in The three costs. In exchange, there is one more file per error.

The global filter, and the failure nobody planned for

The filter translates anything that passes through it into the shape of the contract, and sends the message to the log as a field, not inside the text. Reduced to the two branches that matter:

// 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: [],
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

The middle branch is the one that puts the contract to work: the framework's own exceptions (a guard, the ValidationPipe, a route that does not exist) have no domain code, so fromHttpException derives one from the status and a 401 goes out as unauthorized. A second-class code, but enough for the client not to care where the failure came from.

The client boundary

No client domain code ever sees an axios error. The interceptor normalises it first:

// 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 : [],
  };
}
Enter fullscreen mode Exit fullscreen mode

The server's message is collected and kept even though it will not be rendered. It serves as the fallback described in Honest caveats.

The mirror, the data and the catalogue

The client exception mirrors the server one with two differences that are the whole article: it recognises by code and nothing else, and its message is a value with its two languages inside, not a key into a global dictionary.

// 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

That matches compares only the code is a decision, and the opposite one can be defended too: repeating the status in the mirror is a checkable statement about the contract. The problem is what happens when it fails. If the server changes an error's status (a 409 someone turns into a 422), the mirror stops matching, the error falls into the generic message and nothing fails, neither at compile time nor in the tests.

And this is how an error with data is written:

// 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}.`,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Each operation's catalogue is a declarative list of what can fail, and the resolver walks it:

// 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];
  }
}
Enter fullscreen mode Exit fullscreen mode

The parameters come in through a setter and not as an argument to message(), and that shape has three reasons. The catalogue is a list of instances because that way it composes by extension: a common set of errors that any route can return is added with a spread. Recognising and wording live in the same object, so there are no two artefacts that can drift apart. And the parameters arrive after recognition, because the catalogue is declared without data and the data shows up in the concrete failure. setParams is the exact moment the transport error hands its payload to whoever knows how to word it. And since the mirror already is the error the UI renders, the resolver returns that same instance, with no adapter in between; that is why each catalogue creates the entries that carry data with new: shared, they would read the last failure's data.

With this, the inventory from the opening ends up like this. The text no longer lives in the component, but in the module's catalogue. The catch tells a 503 from a 400 because what it receives is a code, not an opaque exception. The language is still resolved where it is rendered, which was correct and does not change. And the entry that was of a different nature (the exact reason that arrived and was thrown away) is gone: the 32 kg and the limit of 30 arrive named and are used to write the only sentence the person who pressed the button needed to read.

One resolver per operation

Each client operation declares its own catalogue: the failures it knows how to explain and the sentence it gives when it does not recognise the code. Here is the one for printing a label:

// 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();
  }
}
Enter fullscreen mode Exit fullscreen mode

errors() lists only the codes this operation can receive and that are worth explaining; anything not there falls into default(), which is the operation's sentence ("the label could not be printed") and not an app-wide generic one. And order matters: find keeps the first match, which is the first risk in Honest caveats.

The service uses it in a single place, its catch, and from there the component no longer receives a network error: it receives errors that know how to say their sentence in both languages. What it hands to its caller has a type of its own:

// 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 };
Enter fullscreen mode Exit fullscreen mode

The service does not throw: it returns. The union has two members and each one nulls out the other's field, so reaching data means ruling out errors first, and the compiler sees to that: the component has nowhere to skip the failure, and needs no try around it. The plural in errors is the list the resolver returns —here always with one element— and it leaves room for the failure that brings several: a validation complaining about three fields fits the same shape without touching a single signature.

// client/src/modules/shipment/services/print-shipment-label.ts
export async function printShipmentLabel({
  shipmentId,
  carrierId,
}: PrintShipmentLabelDTO): Promise<HttpResponseResult<ShipmentLabel>> {
  try {
    const { data } = await instance.post<ShipmentLabel>(
      `/shipments/${shipmentId}/label`,
      {
        carrierId,
      },
    );

    return { data, errors: null };
  } catch (e) {
    const resolver = new PrintLabelExceptionResolver();

    return { data: null, errors: resolver.execute(e as AppHttpError) };
  }
}
Enter fullscreen mode Exit fullscreen mode

The first entry in the catalogue explains why there is one per operation and not one for the whole app. The server has two limits, a hundred requests per minute in general and three labels per hour, and both answer 429 with the same code, too-many-requests. When dispatching, "try again in a few minutes" is true; when printing a label it is a lie, because that limit resets within an hour. TooManyLabelRequestsHttpException returns the same code() as the generic one with a different sentence, only this catalogue declares it, and the server does not change at all.

Then there is the symmetric case, which comes from the other side and proves the same thing. Talking to the carrier can fail for three reasons the server tells apart: carrier-rate-limit, carrier-connection-error and carrier-error. They are three and should be: in the log they are three investigations. On screen they are a single problem (the carrier did not respond) and a single sentence, so the three client exceptions share a message, and only the outcomes the user can do something about are kept apart.

The same three columns. On the server, CarrierRateLimitException, CarrierConnectionErrorException and CarrierErrorException go out as carrier-rate-limit, carrier-connection-error and carrier-error; their three arrows merge and enter the client's PrintLabelExceptionResolver. Its catalogue also declares too-many-requests and shipment-already-dispatched, dimmed, and highlights the three carrier entries, which resolve to a single sentence: "The carrier did not respond. Please try again". Below, ShipmentNotFoundException goes out as shipment-not-found, which the catalogue does not declare, and falls into the operation's default(): "The label could not be printed".

The three arrows at the top end in the same sentence; the one at the bottom has no entry in errors() and falls into the operation's default().

One code with two sentences, and three codes with one. The mapping between identifier and text is not one-to-one in either direction, and that is why it does not fit on the server: the server's granularity is what failed, and the client's is what you can do about it. They are two different questions and nobody can answer both at once.

Honest caveats

The catalogue can shadow itself, and it does not warn you. If someone adds the common set of errors ahead of the variant, in the catalogue that prints labels, find returns the generic one and the user reads "try again in a few minutes" when the limit is three per hour. No test fails and nothing is logged: the symptom is a true sentence about a different limit, because the two entries share a code and order decides. Today the rule is held up by a comment and discipline; a test that walks the catalogues and fails if any of them declares two entries with the same code would close it.

A client that is not updated does not know the new codes. This is the real objection and the answer is not to deny it: the symptom is that a new error comes out as the generic default() sentence, and in an app nobody updates, forever. That is why the server's message stays in the contract even though this client does not render it: the hierarchy is own catalogue, server text, generic sentence. Using the second step is consciously choosing a degradation (English for someone who does not read it) over saying nothing.

And what this is not. Not an internationalisation system: there is no global dictionary and no keys, the sentence is a value inside the exception. It does not validate forms or replace the log either. And it does not ask the server to stop writing text, only that what it writes goes to the log and to the clients that do not know the code.

Who this is not for, and who it is for

If the API has a single client, in a single language, and its errors are the ones the user cannot fix, the catch from the opening is the right answer: the client's mirror classes would be debt with nothing in return. That case is more common than it seems.

The balance flips with four conditions: two or more clients on the same API; two or more languages; errors with data the user needs in order to fix something; and a code that someone outside your repository already consumes. With two of the four, the contract pays for itself.

And there is a well-documented opposing position worth knowing before copying any of this. Google's API design guide, AIP-193, requires the named parameters ("any request-specific information which contributes to the Status.message […] must be represented within metadata") and still has the server send the sentence already translated, in a LocalizedMessage that "should be localized to a user-specified locale where possible". Neither of the two is wrong: they are two different boundaries. Google writes for public APIs with clients it does not control, and there the server cannot assume there is a catalogue on the other side; this is a backend serving a screen that is controlled.

The practical rule: if the error text carries data inside it, the data travels through the contract and the sentence stays on the client.

The code

The contract, the filter, the shipment and carrier exceptions, the mirrors and one catalogue per operation are in this example project, against @nestjs/common 12.3.3. The server's responses are pinned in files that both sides check: the server, that the real application produces them, and the client, that with those same responses it writes the right sentence for each operation. There are 25 tests, and two of them show both halves of v12's errorCode: the code reaches the client and the data does not.

Let's talk

Questions, feedback or a different take? I would love to read you. You can find me here:

Top comments (0)