An optional collaborator — a logger only configured in production, a cache that may be switched off, a discount that may not apply — produces the same three lines everywhere it is used: check for null, act, or do not. The check is not domain logic, and it is only correct for as long as every call site remembers to write it.
// the same guard, in eleven places
if ($this->logger !== null) {
$this->logger->warning('Stock went negative for SKU ' . $sku);
}
interface Logger
{
public function warning($message);
}
class NullLogger implements Logger
{
public function warning($message) {}
}
// decided once, at construction
public function __construct(Logger $logger = null)
{
$this->logger = $logger ?: new NullLogger();
}
// and from then on, unconditionally
$this->logger->warning('Stock went negative for SKU ' . $sku);
The stand-in has to satisfy the interface honestly, which means returning something the caller can use rather than null: a NullDiscount returns a zero amount, a NullCache returns a miss, and neither announces that it is a stand-in. That last part is what separates this from a flag — code doing instanceof NullLogger has reintroduced the conditional with extra steps. It is the wrong pattern wherever absence is a case the domain has an opinion about. An order with no customer is not an order with a null customer who silently agrees to everything, and hiding that behind a null object converts a loud bug into a quiet wrong answer. Use it where doing nothing is a legitimate behaviour, not where nothing was found.