Understanding the Null Object Pattern
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 anHTTPClientTask
. - 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
, notHTTPClientTask?
, so returningnil
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 thecancel
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
orFeedImageDataStore
continue to work without additional error handling, asNullStore
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 toNullStore
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.