Files

12 KiB

Null Object Pattern

Problem

A logging system is used throughout an application. In production you want real console logging, but in tests or certain configurations you want logging disabled. Without a Null Object, every call site must check if logger is not None before logging, cluttering the code with null checks. Forgetting a check causes a NullPointerException/TypeError.

Solution

Create a NullLogger that implements the same interface as the real logger but does nothing. Code that uses the logger never checks for null — it always has a valid logger object. Swapping between a real logger and a null logger is transparent to the consumer.

Key participants:

  • AbstractLogger — interface declaring log methods
  • ConsoleLogger — real implementation that writes to stdout
  • NullLogger — no-op implementation that silently ignores all calls
  • Client — uses the logger without null checks

When to Use

  • You want to eliminate null checks throughout the codebase
  • A default "do nothing" behavior is a valid option
  • You need to swap between real and no-op implementations transparently
  • Testing code that uses dependencies you want to silence

When to Avoid

  • The absence of an object is meaningful and should be handled explicitly
  • Null objects hide bugs where a real object should have been provided
  • The interface has methods that return meaningful values (null object must return sensible defaults)
  • You want to fail fast when an object is missing, not silently continue

Pseudocode

INTERFACE Logger:
    method info(message)
    method warn(message)
    method error(message)

CLASS ConsoleLogger IMPLEMENTS Logger:
    method info(message):
        PRINT "[INFO] " + message
    method warn(message):
        PRINT "[WARN] " + message
    method error(message):
        PRINT "[ERROR] " + message

CLASS NullLogger IMPLEMENTS Logger:
    method info(message):  // do nothing
    method warn(message):  // do nothing
    method error(message): // do nothing

// Usage — no null checks needed:
service = Service(logger=NullLogger())  // silent
service = Service(logger=ConsoleLogger())  // verbose

Python

from __future__ import annotations
from abc import ABC, abstractmethod


class Logger(ABC):
    @abstractmethod
    def info(self, message: str) -> None:
        pass

    @abstractmethod
    def warn(self, message: str) -> None:
        pass

    @abstractmethod
    def error(self, message: str) -> None:
        pass


class ConsoleLogger(Logger):
    def __init__(self, name: str = "App") -> None:
        self.name = name

    def info(self, message: str) -> None:
        print(f"  [{self.name}] INFO:  {message}")

    def warn(self, message: str) -> None:
        print(f"  [{self.name}] WARN:  {message}")

    def error(self, message: str) -> None:
        print(f"  [{self.name}] ERROR: {message}")


class NullLogger(Logger):
    """No-op logger — silently ignores all log calls."""

    def info(self, message: str) -> None:
        pass

    def warn(self, message: str) -> None:
        pass

    def error(self, message: str) -> None:
        pass


class UserService:
    """Service that uses a logger. No null checks anywhere."""

    def __init__(self, logger: Logger) -> None:
        self.logger = logger
        self.users: dict[str, str] = {}

    def create_user(self, user_id: str, name: str) -> None:
        self.logger.info(f"Creating user: {name} (id={user_id})")
        if user_id in self.users:
            self.logger.warn(f"User {user_id} already exists, overwriting")
        self.users[user_id] = name
        self.logger.info(f"User created successfully")

    def get_user(self, user_id: str) -> str | None:
        self.logger.info(f"Looking up user: {user_id}")
        if user_id not in self.users:
            self.logger.error(f"User {user_id} not found")
            return None
        return self.users[user_id]

    def delete_user(self, user_id: str) -> bool:
        self.logger.info(f"Deleting user: {user_id}")
        if user_id not in self.users:
            self.logger.warn(f"Cannot delete: user {user_id} not found")
            return False
        del self.users[user_id]
        self.logger.info(f"User {user_id} deleted")
        return True


def main() -> None:
    print("=== With ConsoleLogger ===")
    service = UserService(ConsoleLogger("UserSvc"))
    service.create_user("u1", "Alice")
    service.create_user("u1", "Alice Updated")
    service.get_user("u1")
    service.get_user("u999")
    service.delete_user("u1")
    service.delete_user("u1")

    print("\n=== With NullLogger (silent) ===")
    silent_service = UserService(NullLogger())
    silent_service.create_user("u1", "Alice")
    silent_service.create_user("u1", "Alice Updated")
    result = silent_service.get_user("u999")
    print(f"  get_user('u999') returned: {result}")
    deleted = silent_service.delete_user("u1")
    print(f"  delete_user('u1') returned: {deleted}")

    print("\n=== Both services work identically — only output differs ===")


if __name__ == "__main__":
    main()

Output:

=== With ConsoleLogger ===
  [UserSvc] INFO:  Creating user: Alice (id=u1)
  [UserSvc] INFO:  User created successfully
  [UserSvc] INFO:  Creating user: Alice Updated (id=u1)
  [UserSvc] WARN:  User u1 already exists, overwriting
  [UserSvc] INFO:  User created successfully
  [UserSvc] INFO:  Looking up user: u1
  [UserSvc] INFO:  Looking up user: u999
  [UserSvc] ERROR: User u999 not found
  [UserSvc] INFO:  Deleting user: u1
  [UserSvc] INFO:  User u1 deleted
  [UserSvc] INFO:  Deleting user: u1
  [UserSvc] WARN:  Cannot delete: user u1 not found

=== With NullLogger (silent) ===
  get_user('u999') returned: None
  delete_user('u1') returned: True

=== Both services work identically — only output differs ===

Go

package main

import "fmt"

type Logger interface {
	Info(message string)
	Warn(message string)
	Error(message string)
}

// ConsoleLogger prints to stdout
type ConsoleLogger struct {
	Name string
}

func (l *ConsoleLogger) Info(message string) {
	fmt.Printf("  [%s] INFO:  %s\n", l.Name, message)
}
func (l *ConsoleLogger) Warn(message string) {
	fmt.Printf("  [%s] WARN:  %s\n", l.Name, message)
}
func (l *ConsoleLogger) Error(message string) {
	fmt.Printf("  [%s] ERROR: %s\n", l.Name, message)
}

// NullLogger does nothing
type NullLogger struct{}

func (l *NullLogger) Info(message string)  {}
func (l *NullLogger) Warn(message string)  {}
func (l *NullLogger) Error(message string) {}

type UserService struct {
	logger Logger
	users  map[string]string
}

func NewUserService(logger Logger) *UserService {
	return &UserService{logger: logger, users: make(map[string]string)}
}

func (s *UserService) CreateUser(id, name string) {
	s.logger.Info(fmt.Sprintf("Creating user: %s (id=%s)", name, id))
	if _, exists := s.users[id]; exists {
		s.logger.Warn(fmt.Sprintf("User %s already exists, overwriting", id))
	}
	s.users[id] = name
	s.logger.Info("User created successfully")
}

func (s *UserService) GetUser(id string) (string, bool) {
	s.logger.Info(fmt.Sprintf("Looking up user: %s", id))
	name, ok := s.users[id]
	if !ok {
		s.logger.Error(fmt.Sprintf("User %s not found", id))
		return "", false
	}
	return name, true
}

func (s *UserService) DeleteUser(id string) bool {
	s.logger.Info(fmt.Sprintf("Deleting user: %s", id))
	if _, exists := s.users[id]; !exists {
		s.logger.Warn(fmt.Sprintf("Cannot delete: user %s not found", id))
		return false
	}
	delete(s.users, id)
	s.logger.Info(fmt.Sprintf("User %s deleted", id))
	return true
}

func main() {
	fmt.Println("=== With ConsoleLogger ===")
	svc := NewUserService(&ConsoleLogger{Name: "UserSvc"})
	svc.CreateUser("u1", "Alice")
	svc.CreateUser("u1", "Alice Updated")
	svc.GetUser("u1")
	svc.GetUser("u999")
	svc.DeleteUser("u1")
	svc.DeleteUser("u1")

	fmt.Println("\n=== With NullLogger (silent) ===")
	silent := NewUserService(&NullLogger{})
	silent.CreateUser("u1", "Alice")
	silent.CreateUser("u1", "Alice Updated")
	_, found := silent.GetUser("u999")
	fmt.Printf("  GetUser(\"u999\") found: %v\n", found)
	deleted := silent.DeleteUser("u1")
	fmt.Printf("  DeleteUser(\"u1\") returned: %v\n", deleted)

	fmt.Println("\n=== Both services work identically - only output differs ===")
}

Output:

=== With ConsoleLogger ===
  [UserSvc] INFO:  Creating user: Alice (id=u1)
  [UserSvc] INFO:  User created successfully
  [UserSvc] INFO:  Creating user: Alice Updated (id=u1)
  [UserSvc] WARN:  User u1 already exists, overwriting
  [UserSvc] INFO:  User created successfully
  [UserSvc] INFO:  Looking up user: u1
  [UserSvc] INFO:  Looking up user: u999
  [UserSvc] ERROR: User u999 not found
  [UserSvc] INFO:  Deleting user: u1
  [UserSvc] INFO:  User u1 deleted
  [UserSvc] INFO:  Deleting user: u1
  [UserSvc] WARN:  Cannot delete: user u1 not found

=== With NullLogger (silent) ===
  GetUser("u999") found: false
  DeleteUser("u1") returned: true

=== Both services work identically - only output differs ===

JavaScript

class ConsoleLogger {
  constructor(name = "App") {
    this.name = name;
  }

  info(message) {
    console.log(`  [${this.name}] INFO:  ${message}`);
  }

  warn(message) {
    console.log(`  [${this.name}] WARN:  ${message}`);
  }

  error(message) {
    console.log(`  [${this.name}] ERROR: ${message}`);
  }
}

class NullLogger {
  info(message) {}
  warn(message) {}
  error(message) {}
}

class UserService {
  constructor(logger) {
    this.logger = logger;
    this.users = new Map();
  }

  createUser(id, name) {
    this.logger.info(`Creating user: ${name} (id=${id})`);
    if (this.users.has(id)) {
      this.logger.warn(`User ${id} already exists, overwriting`);
    }
    this.users.set(id, name);
    this.logger.info("User created successfully");
  }

  getUser(id) {
    this.logger.info(`Looking up user: ${id}`);
    if (!this.users.has(id)) {
      this.logger.error(`User ${id} not found`);
      return null;
    }
    return this.users.get(id);
  }

  deleteUser(id) {
    this.logger.info(`Deleting user: ${id}`);
    if (!this.users.has(id)) {
      this.logger.warn(`Cannot delete: user ${id} not found`);
      return false;
    }
    this.users.delete(id);
    this.logger.info(`User ${id} deleted`);
    return true;
  }
}

// --- Demo ---
console.log("=== With ConsoleLogger ===");
const svc = new UserService(new ConsoleLogger("UserSvc"));
svc.createUser("u1", "Alice");
svc.createUser("u1", "Alice Updated");
svc.getUser("u1");
svc.getUser("u999");
svc.deleteUser("u1");
svc.deleteUser("u1");

console.log("\n=== With NullLogger (silent) ===");
const silent = new UserService(new NullLogger());
silent.createUser("u1", "Alice");
silent.createUser("u1", "Alice Updated");
const result = silent.getUser("u999");
console.log(`  getUser("u999") returned: ${result}`);
const deleted = silent.deleteUser("u1");
console.log(`  deleteUser("u1") returned: ${deleted}`);

console.log("\n=== Both services work identically - only output differs ===");

Output:

=== With ConsoleLogger ===
  [UserSvc] INFO:  Creating user: Alice (id=u1)
  [UserSvc] INFO:  User created successfully
  [UserSvc] INFO:  Creating user: Alice Updated (id=u1)
  [UserSvc] WARN:  User u1 already exists, overwriting
  [UserSvc] INFO:  User created successfully
  [UserSvc] INFO:  Looking up user: u1
  [UserSvc] INFO:  Looking up user: u999
  [UserSvc] ERROR: User u999 not found
  [UserSvc] INFO:  Deleting user: u1
  [UserSvc] INFO:  User u1 deleted
  [UserSvc] INFO:  Deleting user: u1
  [UserSvc] WARN:  Cannot delete: user u1 not found

=== With NullLogger (silent) ===
  getUser("u999") returned: null
  deleteUser("u1") returned: true

=== Both services work identically - only output differs ===
  • Strategy — the Null Object is effectively a null/no-op Strategy; swapping a real strategy for a NullObject is a natural pattern.
  • Result Type — Result Type makes absence explicit and forces handling; Null Object hides absence. Choose based on whether silence is acceptable.
  • Guard Clause — guard clauses check for null at the boundary; Null Object eliminates the need for those checks inside the function body.