Mathieu Eveillard

Bug, error, or expected case: what is the business intent?

When should you throw an exception, return an error, or model the absence of a value? The answer lies in the business intent.

Bug, error, or expected case: what is the business intent?

We’ve seen in the past that it’s important to distinguish between two types of errors:

  • Development errors, that is, bugs, for which it is justified to throw exceptions (since, by definition, we don’t know how to handle them);
  • User input or infrastructure errors, which are predictable and will occur sooner or later. These errors are best handled using the Result monad (type Result<U, E> = Success<U> | Error<E>) and Railway-Oriented Programming.

At this point, a common pitfall would be to never throw exceptions again and to “put everything in Result.” That’s a bad idea, of course. So let’s add some nuance.

To better understand the topic, let’s consider a logistics context and a ShipmentPort that expresses the domain’s persistence requirements, in a ports-and-adapters architecture (a.k.a., Hexagonal Architecture).

Let’s now look at these three signatures: getRequiredShipmentById, getShipmentById, and getNextScheduledShipmentAfterDate. We’ll see that they imply very distinct meanings from a business perspective.

  1. getRequiredShipmentById: here, we expect the Shipment to exist in the database. If it does not, it means that we, as developers, have failed to respect a business invariant—it’s a bug. By definition, this is a case we do not know how to handle, so we should throw an exception (throw). In TypeScript, a function’s signature does not specify the exceptions that the function might throw. We therefore indicate this behavior through the function name: Required, or sometimes OrThrow.

  2. getShipmentById: here, the shipmentId is derived from user input. The case where no Shipment matches the ID provided by the user is therefore entirely predictable and must be accounted for. This is a business error, since the application cannot function without Shipment, but it’s a type of error we know how to handle, using the Error subtype of the Result monad.

  3. getNextScheduledShipmentAfterDate: here, talking about an “error” is actually a misnomer, since the absence of a shipment is part of the normal flow, for example, creating the Shipment if it doesn’t already exist. Note here the use of the Either monad (type Either<U> = Some<U> | None), which is a way to represent the absence of Shipment (neater than Shipment | null, which forces us to perform multiple checks). “That’s a lot of monads,” you might say, but once you’ve tried them, it’s hard to go back 🙂

One might think that the third case introduces a new type of error. But as we’ve seen, this case is actually an expected case, so there are only two error cases, those mentioned in the introduction: bugs or predictable errors.

What should we take away from all this? That it’s the business that determines which case we’re in:

  • Does a Shipment have to exist at this stage of the logistics process?
  • Is its absence a realistic possibility?
  • Does this absence trigger a business action?

Note also that ShipmentId is the encapsulation of an ID with nominal type emulation, an approach that helps avoid many errors related to “primitive obsession.”

← All articles