0

Fail fast vs defensive programming — when to throw vs when to return null

Advanced5 min read·php-10-012
interviewsolid

Concept

Fail fast and defensive programming are two philosophies for handling unexpected conditions. Understanding when to apply each produces more reliable and maintainable code.

Fail fast: Detect problems as early as possible, throw an exception immediately when a precondition is violated, and never continue with bad state. This approach: surfaces bugs early (at the call site, not 10 stack frames later), makes debugging easier (the exception points directly to the bad input), and prevents corrupt state from propagating.

Defensive programming: Attempt to handle or recover from every possible bad input, often silently. Return null, use defaults, skip invalid items. This approach: makes code more robust to imperfect input, can hide bugs (bad input silently becomes a default value), and increases complexity.

The right balance:

  • At system boundaries (HTTP requests, file I/O, external APIs): be defensive. Validate and sanitize all input. The outside world is unpredictable.
  • At internal boundaries (between your own classes): fail fast. If a method in your own system receives bad input, it's a programming error — throw immediately.
  • For optional dependencies: use the Null Object pattern (not defensive null checks scattered everywhere).
  • Business rule violations: throw domain exceptions explicitly rather than returning null/false.

When to return null vs throw: Return null when "nothing found" is a valid, expected outcome (e.g., findById — the record may not exist). Throw when a required contract is violated (e.g., findByIdOrFail — caller asserts the record must exist).

Code Example

php
<?php
declare(strict_types=1);

// FAIL FAST — good for internal code
class OrderProcessor
{
    public function process(Order $order): void
    {
        // Precondition checks — throw immediately if violated
        if ($order->getId() === null) {
            throw new \LogicException("Order must have an ID before processing");
        }
        if ($order->getItems()->isEmpty()) {
            throw new \InvalidArgumentException("Cannot process an empty order");
        }

        // We're sure the data is valid from here on
        $this->chargePayment($order);
        $this->updateInventory($order);
    }
}

// DEFENSIVE — good for system boundaries (user input)
class CreateOrderRequest
{
    public function validate(array $input): array
    {
        $errors = [];

        if (empty($input['email'])) {
            $errors['email'] = 'Email is required';
        } elseif (!filter_var($input['email'], FILTER_VALIDATE_EMAIL)) {
            $errors['email'] = 'Email is invalid';
        }

        if (empty($input['items'])) {
            $errors['items'] = 'At least one item is required';
        }

        return $errors; // Return errors, let caller decide what to do
    }
}

// Return null vs throw — contextual decision
class UserRepository
{
    // Returns null — caller decides if missing user is an error
    public function findById(int $id): ?User
    {
        return $this->db->query("SELECT * FROM users WHERE id = ?", [$id])->first();
    }

    // Throws — caller asserts user MUST exist
    public function findByIdOrFail(int $id): User
    {
        return $this->findById($id)
            ?? throw new UserNotFoundException("User $id not found");
    }
}

// Anti-pattern: returning false/null to indicate error (fail quietly)
function badDivide(int $a, int $b): int|false
{
    if ($b === 0) return false; // Caller might ignore this!
    return intdiv($a, $b);
}

// Better: throw immediately
function divide(int $a, int $b): int
{
    if ($b === 0) throw new \DivisionByZeroError("Cannot divide by zero");
    return intdiv($a, $b);
}