Understanding the Null Object Pattern

abdul ahad
6 min readJan 7, 2025

--

Photo by Kyan Tijhuis on Unsplash

In software development, handling cases where an object or functionality is absent is a common challenge. Many developers resort to using null values or conditional checks to handle such scenarios. However, these approaches can lead to bloated, error-prone, and harder-to-read code. Enter the Null Object Pattern — a clean and elegant solution to this problem.

What is the Null Object Pattern?

The Null Object Pattern is a behavioral design pattern where a special object with “neutral” behavior represents the absence of a meaningful object. Instead of returning null or using conditional checks, a Null Object ensures that the system behaves predictably by providing safe, default behavior.

When to Use the Null Object Pattern

The pattern is particularly useful in scenarios where:

1. Default behavior is needed:

  • A fallback is required when the primary implementation is unavailable.

2. Conditional logic overwhelms code:

  • To reduce complexity by removing if-else checks.

3. Fallback Strategy for Non-Critical Components:

  • Use it for components that are not critical to the app’s core functionality but are still required for a seamless experience (e.g., caching, logging, or analytics).
  • Example: If a database or cache fails, fall back to a NullStore to prevent the app from crashing while preserving a consistent state.

4. To Avoid Breaking the System:

  • The pattern ensures that the app doesn’t enter a weird/unrecoverable state by providing neutral implementations.

5. When It’s Preferable to Continue with Limited Functionality:

  • If the app can function in a degraded state without a certain component, use a Null Object instead of crashing or hanging.

Example 1: Cancelling Http Requests Problem:

In the context of building an HTTP client in Swift, you have a perform function that sends a network request and returns an HTTPClientTask. This task allows the caller to control the request, such as canceling it if needed. Here's a simplified version of the function:

public func perform(request: URLRequest, completion: @escaping (HTTPClient.Result) -> Void) -> HTTPClientTask {
// Attempt to load authentication tokens
guard let authTokens = tokenStore.load() else {
// If tokens are missing, report an error
completion(.failure(Error.missingAccessToken))
// Problem: Need to return an HTTPClientTask here
}

// Sign the request with the authentication tokens
var signedRequest = request
signedRequest.setValue("Bearer \(authTokens.accessToken)", forHTTPHeaderField: "Authorization")

// Proceed with the signed request
return decoratee.perform(request: signedRequest, completion: completion)
}

The Core Issue:

  • Function Signature Constraint: The perform function must always return an HTTPClientTask.
  • Early Exit Scenario: When the authentication tokens are missing, we need to exit the function early after calling the completion handler with an error.
  • Dilemma: Since we’re exiting early, there’s no actual network task to return. However, the function signature requires us to return an HTTPClientTask.

Why Can’t We Simply Return Nothing or Nil?

  • Non-Optional Return Type: The function’s return type is HTTPClientTask, not HTTPClientTask?, so returning nil is not an option.
  • Contractual Obligation: The callers of this function expect an HTTPClientTask to manage the request lifecycle, even if the request fails immediately.

Potential (but problematic) Solutions:
1. Changing the Return Type to Optional:
Modifying the return type to HTTPClientTask? would affect all callers and require additional nil-checks, leading to less clean code.

2. Throwing Exceptions: Making the function throws complicates the interface and shifts the error handling responsibility to the caller.

3. Returning a Partially Initialized Task: This could lead to runtime errors if the caller tries to use an improperly initialized task.

Explanation of the Solution:

To elegantly handle this situation, we can use the Null Object Pattern.

Implementing the Null Object Pattern in This Case:

1. Create a NullTask:

We define a NullTask struct that conforms to the HTTPClientTask protocol but doesn't perform any action when its methods are invoked.

struct NullTask: HTTPClientTask {    
func cancel() {
// Do nothing
}
}

2. Modify the perform function:

We adjust the perform function to return a NullTask when the authentication tokens are missing.

public func perform(request: URLRequest, completion: @escaping (HTTPClient.Result) -> Void) -> HTTPClientTask {
guard let authTokens = tokenStore.load() else {
completion(.failure(Error.missingAccessToken))
return NullTask() // Return a NullTask to satisfy the return type
}

var signedRequest = request
signedRequest.setValue("Bearer \(authTokens.accessToken)", forHTTPHeaderField: "Authorization")
return decoratee.perform(request: signedRequest, completion: completion)
}

Why This Solution Works:

  • Satisfies Function Signature: By returning a NullTask, we adhere to the function's required return type without altering its signature or introducing optionals.
  • Safe to Use: The NullTask safely implements the cancel method without performing any action, preventing potential side effects or errors.
  • Transparent to Callers: Callers can interact with the returned HTTPClientTask as usual, without needing to check for special cases or null values.
  • Simplifies Error Handling: The error is already communicated through the completion handler, so the caller is informed of the failure without additional mechanisms.

Benefits of Using the Null Object Pattern Here:

1- Avoids Conditional Logic in Caller Code:

  • Callers don’t need to write extra code to handle a nil task or check if the task is a special case.
  • Simplifies the caller’s codebase, making it cleaner and more maintainable.

2. Encapsulates Do-Nothing Behavior:

  • The NullTask encapsulates the behavior of a non-operative task, adhering to the Single Responsibility Principle.
  • Future changes to how non-operative tasks behave can be made within NullTask without affecting other parts of the code.

3. Improves Code Robustness:

  • Reduces the risk of runtime errors by ensuring that all returned tasks conform to the expected protocol.
  • Makes the system more resilient to changes and easier to reason about.

Example 2: The Null Object Pattern as a Fallback Solution for Database Initialization Failure

The Null Object Pattern also provides a fallback strategy that allows the system to continue functioning in a degraded but consistent state.

1. CoreData Initialization Errors

CoreData may fail to initialize due to various reasons:

  • Programmer errors (e.g., faulty database migrations).
  • Environmental issues (e.g., lack of disk space on the user’s device).

If initialization fails:

  • The app cannot work with a faulty CoreDataFeedStore.
  • Ignoring the error or proceeding with a faulty instance can make the app unusable.

2. Fallback Strategy with NullStore

Instead of letting the app crash or fail silently, we use a NullStore, which:

  • Implements the same protocols (as CoreData implementation).
  • Provides neutral implementations that return “safe” results for all operations.
  • Implementation of NullStore:
protocol FeedStore {

func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion)

func retrieve(completion: @escaping RetrievalCompletion)

func insert(_ data: Data, for url: URL, completion: @escaping (InsertionResult) -> Void)
}

class NullStore: FeedStore {

func insert(_ feed: [LocalFeedImage], timestamp: Date, completion: @escaping InsertionCompletion) {
completion(.success(())) // Neutral behavior: insertion is always "successful"
}

func retrieve(completion: @escaping RetrievalCompletion) {
completion(.success(.none)) // Neutral behavior: no data is retrieved
}

func insert(_ data: Data, for url: URL, completion: @escaping (InsertionResult) -> Void) {
completion(.success(())) // Neutral behavior: data insertion is always "successful"
}

}

How It Works:

  • NullStore ensures that the app does not hang or crash due to unhandled errors.
  • Clients that interact with the FeedStore or FeedImageDataStore continue to work without additional error handling, as NullStore handles all requests gracefully.

3. Using NullStore as a Fallback

When initializing CoreDataFeedStore, we can catch errors and provide ⁣NullStore as a fallback:

do {
return try CoreDataFeedStore(...)
} catch {
return NullStore()
}
  • If CoreDataFeedStore fails, the app switches to NullStore and continues functioning in a degraded state.

However remember Fallback is Not a Permanent Solution:

  • The Null Object Pattern is a fallback, not a substitute for addressing the underlying issue.
  • Work towards resolving the errors to avoid relying on the Null Object.

Conclusion:

By applying the Null Object Pattern, you can create fault-tolerant systems that gracefully handle errors, encapsulates Do-Nothing Behavior and continue functioning without breaking the user experience. However, at times it’s critical to monitor such fallback scenarios to identify and fix the root causes.

References:

--

--

abdul ahad
abdul ahad

Written by abdul ahad

A software developer dreaming to reach the top and also passionate about sports and language learning

No responses yet