12 KiB
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 ===
Related Patterns
- 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.