Exceptions are used in many object-oriented languages to handle failures. They provide an alternative control flow that allows you to skip normal method execution. This can be a powerful way to signal errors, but it also introduces invisible state that can be hard to reason about. This post argues that exceptions should only be used for exceptional or unexpected situations, not for regular control flow.

The rule of thumb is:

Exceptions are for exceptional behavior.

So, don’t use them when the failure is expected or part of the normal domain logic.

While this post uses Java examples, the principle applies broadly across many object-oriented and functional languages such as C#, Kotlin, Python, and more. The core idea is that exceptions should represent truly unexpected or exceptional situations — not routine domain logic or user errors.

Checked vs. Unchecked Exceptions in Java

In Java, exceptions can be either checked or unchecked:

  • Checked exceptions must be declared in method signatures, making the possible failures more visible in the code.
  • Unchecked exceptions don't require declaration, and are often used instead because checked exceptions can be harder to work with.

However, in practice, developers often either:

  • Convert checked exceptions to unchecked ones to simplify usage, or
  • Declare checked exceptions on every method — which reduces clarity, since everything appears to throw an error.

So even though Java offers structure, misuse often leads to less maintainable code.

Expected vs. Unexpected Failures

What counts as "expected" or "unexpected" depends on application context:

  • In a microservice, input is controlled and validated by upstream services — bad input might be unexpected.
  • In a user-facing application, users can send anything — bad input must be expected.

Domain logic is full of expected failure scenarios. These should not throw exceptions. In fact, if a domain rule can't fail, it's probably not a rule worth checking.

Handling null

  • Expected null values often come from external input. These should be handled explicitly via contracts (e.g. annotations like @NotNull) or validation at the boundary.
  • Unexpected null values passed internally (e.g. violating method contracts) usually indicate bugs, and throwing an exception may be appropriate.

Integrations and Communication

When communicating with other systems, define whether failures are expected:

Case Expected Failure? Should Throw?
Database connection Unexpected Yes
External system with uptime guarantees Unexpected Yes
Third-party service with no guarantees Expected No
Message queue with optional availability Expected No

Design-time decisions should define what’s exceptional. For example, connecting to an MQ server might be required to succeed, but not receiving a response (e.g., due to no available messages) might be expected.

Handling Expected Returns

Consider the following domain rule validation:

public static void verifyDomain(int domainNumber) {
    final var isAbove15 = domainNumber > 15;
    final var topNumber = !isAbove15 || domainNumber % 3 == 1;
    final var bottomNumber = isAbove15 || domainNumber % 2 == 0;
    final var isValid = topNumber && bottomNumber;

    if (!isValid) {
        throw new IllegalArgumentException("Number is not top or bottom");
    }
}

This approach leads to verbose and brittle test code:

@ParameterizedTest(name = "Valid domain number: {0}")
@ValueSource(ints = {1, 2, 4, 13, 16, 18, 30})
void testValid(int number) {
    assertDoesNotThrow(() -> verifyDomain(number));
}

@ParameterizedTest(name = "Invalid domain number: {0}")
@ValueSource(ints = {3, 5, 6, 9, 15, 17, 19})
void testInvalid(int number) {
    var ex = assertThrows(IllegalArgumentException.class, () -> verifyDomain(number));
    assertEquals("Number is not top or bottom", ex.getMessage());
}

And if you later want to return a better error to the user, you're forced to do something like:

try {
    verifyDomain(domainNumber);
} catch (IllegalArgumentException ex) {
    throw BeautifulError.domainNumber(domainNumber);
}

A Better Approach: Return Values

Since this is expected logic, returning a boolean (or a richer type) is clearer:

public static boolean verifyDomain(int domainNumber) {
    final var isAbove15 = domainNumber > 15;
    final var topNumber = !isAbove15 || domainNumber % 3 == 1;
    final var bottomNumber = isAbove15 || domainNumber % 2 == 0;
    return topNumber && bottomNumber;
}

Tests become simpler and more expressive:

@ParameterizedTest(name = "Valid domain number: {0}")
@ValueSource(ints = {1, 2, 4, 13, 16, 18, 30})
void testValid(int number) {
    assertTrue(verifyDomain(number));
}

@ParameterizedTest(name = "Invalid domain number: {0}")
@ValueSource(ints = {3, 5, 6, 9, 15, 17, 19})
void testInvalid(int number) {
    assertFalse(verifyDomain(number));
}

So does application logic:

if (!verifyDomain(domainNumber)) {
    throw BeautifulError.domainNumber(domainNumber);
}

This improves readability, testability, and separation of concerns.

For more advanced use cases, consider returning Optional, or a custom Result<T, E> type to model both success and failure explicitly.

Conclusion

Using exceptions to control expected failures adds unnecessary complexity. Especially in domain logic, they can obscure what the code is really trying to express.

Instead:

  • Handle expected failures using return types (boolean, Optional, Result, etc.)
  • Reserve exceptions for truly exceptional or unexpected scenarios.

Remember:

Exceptions are for exceptional behavior.