Bug, error, or nominal 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
Resultmonad (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, within the framework of a port/adapter 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.
-
getRequiredShipmentById: here, it is expected that theShipmentexists 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 sometimesOrThrow. Note that we still use theResulttype, because there may be other errors—ones that are, in fact, quite predictable (such as the database not responding…). -
getShipmentById: here, theshipmentIdis derived from user input. The case where noShipmentmatches the ID provided by the user is therefore entirely predictable and must be accounted for. This is a functional error, since the application cannot function withoutShipment, but it’s a type of error we know how to handle, using theErrorsubtype of theResultmonad. -
getNextScheduledShipmentAfterDate: here, talking about an “error” is actually a misnomer, since the absence of an error is part of the nominal scenario—for example, creating theShipmentif it doesn’t already exist. Note here the use of theEithermonad (type Either<U> = Some<U> | None), which is a way to represent the absence ofShipment(neater thanShipment | null, which force 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 a nominal 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 is the business logic that determines which case we’re in:
- Does a
Shipmenthave to exist at this stage of the logistics process? - Is its absence a realistic possibility?
- Does this absence trigger a business action?
Note: It’s also worth noting that ShipmentId is the encapsulation of an ID with nominal type emulation—an approach that helps avoid many errors related to “primitive obsession.”