0

Null Object pattern — eliminating null checks

Intermediate5 min read·eng-04-011
interview

Concept

Null Object pattern eliminates null checks by providing an object with do-nothing (neutral) behavior in place of null. Instead of if ($logger !== null) { $logger->log(...); }, you use a NullLogger that implements the same interface but does nothing.

The problem with null: Null references require defensive checks everywhere. A missing null check throws TypeError: Call to a member function log() on null. Null Object provides a safe, predictable object that simply does nothing.

Structure: The Null Object implements the same interface as the "real" object. All methods do nothing or return neutral values (empty array, false, 0, empty string).

When to use Null Object:

  • Optional dependencies that are sometimes not configured.
  • Default/fallback behavior that is "do nothing".
  • Simplifying code that has many if ($obj !== null) guards.

Null Object vs null: Null Object is a valid object — you can call methods on it safely. It's the "zero value" of the object's domain. null is the absence of an object.

PHP 8 nullsafe operator (?->): $logger?->log(...) is an alternative for single calls. Null Object is better when: you pass the dependency around, you call it in multiple places, or you want polymorphic behavior (swap real with null).

Examples:

  • NullLogger implementing PSR-3 LoggerInterface.
  • NullCache implementing CacheInterface.
  • NullEventDispatcher.
  • NullTransport for testing mailers.

Code Example

php
<?php
// Real implementation
interface LoggerInterface
{
    public function info(string $message, array $context = []): void;
    public function warning(string $message, array $context = []): void;
    public function error(string $message, array $context = []): void;
}

class FileLogger implements LoggerInterface
{
    public function __construct(private readonly string $path) {}
    public function info(string $message, array $context = []): void    { $this->write("INFO: {$message}", $context); }
    public function warning(string $message, array $context = []): void  { $this->write("WARNING: {$message}", $context); }
    public function error(string $message, array $context = []): void    { $this->write("ERROR: {$message}", $context); }
    private function write(string $msg, array $ctx): void { file_put_contents($this->path, $msg . "\n", FILE_APPEND); }
}

// Null Object — safe no-op implementation
class NullLogger implements LoggerInterface
{
    public function info(string $message, array $context = []): void    {}
    public function warning(string $message, array $context = []): void  {}
    public function error(string $message, array $context = []): void    {}
}

// Service — requires a logger
class UserImporter
{
    public function __construct(
        private readonly LoggerInterface $logger,  // always set, never null
    ) {}

    public function import(array $users): void
    {
        $this->logger->info('Starting import', ['count' => count($users)]);

        foreach ($users as $user) {
            try {
                User::create($user);
                $this->logger->info('Imported user', ['email' => $user['email']]);
            } catch (\Exception $e) {
                $this->logger->error('Failed to import', ['email' => $user['email'], 'error' => $e->getMessage()]);
            }
        }

        $this->logger->info('Import complete');
        // NO null checks — safe because logger is always a valid object
    }
}

// Usage — swap real vs null logger
$importerWithLogging = new UserImporter(new FileLogger('/logs/import.log'));
$importerSilent      = new UserImporter(new NullLogger()); // no logging, no null checks needed

// Container — default to NullLogger if not configured
$logger = config('logging.enabled')
    ? new FileLogger(storage_path('logs/app.log'))
    : new NullLogger();
app()->instance(LoggerInterface::class, $logger);

// Testing — NullLogger lets you test without file I/O
class UserImporterTest extends \PHPUnit\Framework\TestCase
{
    public function test_imports_users(): void
    {
        $importer = new UserImporter(new NullLogger());
        $importer->import([['email' => 'alice@example.com', 'name' => 'Alice']]);
        // No need to mock logger — NullLogger is perfectly valid
    }
}