Networking with NSOperation as Your Wingman

04 Feb 2016 11 min read

Sometimes the tools that make our lives easier also make our architecture worse. When something is hard to do, we build structure around it, and that structure often leads us to make smarter decisions and be more thoughtful with what does what. When a new tool removes that difficulty, we remove the structure with it - and in doing so, lose the benefits we never even realised the structure gave us.

When NSURLConnection was the only networking option on iOS, its delegate-based approach required so much boilerplate that developers naturally pushed networking code out of their view controllers and into dedicated classes. The boilerplate was tedious, but it had an architectural side effect worth preserving - a distinct networking layer with clear responsibilities. When NSURLSession was released with its far more developer-friendly, closure-based interface, we jumped at the chance to remove all that boilerplate. But the ease of making network calls meant that we no longer had to keep networking at arm's length. Network requests, JSON parsing, and response handling started appearing directly in view controllers alongside their existing responsibilities πŸ’₯ - a quiet erosion of the single responsibility principle that made our codebases harder to understand and more brittle to change.

Photo of a vintage aircraft

In this post, I want to explore how we can use NSOperation and NSOperationQueue to get back to an isolated networking layer.

This post is based on an Objective-C example. If you are more interested in seeing how to combine networking and operations based on a Swift example, head over to the more recent "Building a networking layer with operations" post.

Getting the Lay of the Land

Before jumping into the details, let's take a moment to look at the overall architecture we're going to put in place and how each component fits into it.

Our networking layer will have three main responsibilities:

  1. Scheduling network requests.
  2. Performing networking requests.
  3. Parsing the response from network requests.

These responsibilities will come together to produce the following class structure:

Class diagram showing the networking layer architecture. An external caller uses APIManager. APIManager creates an Operation and schedules it on OperationQueueManager. OperationQueueManager manages any Operation it has been given

  • The entry point represents whichever feature triggers the data fetch; it could be a view controller, service, etc.
  • APIManager creates an operation and schedules it on the OperationQueueManager. Ideally, the feature should never care where the data that powers it comes from; the APIManager ensures that it doesn't.
  • OperationQueueManager is a singleton that controls when operations are executed.
  • Operation is a concurrent NSOperation subclass that encapsulates the network request and parsing for a given endpoint.

I've used generic naming in the class diagram, but in the example, we will be working in concrete types, so APIManager will become NWMAnswersAPIManager and Operation will become NWMAnswersRetrievalOperation, but any APIManager or Operation could be substituted in.

Don't worry if that doesn't all make sense yet; we will look into each class in greater depth below.

Let's take a small recap of what NSOperationQueue and NSOperation are.

NSOperationQueue is responsible for coordinating the execution of operations. Rather than executing work immediately, it schedules operations based on each operation's readiness, priority, dependencies, and available system resources.

Because NSOperationQueue maintains visibility over its operations, we can inspect, pause, or cancel them β€” for example, cancelling in-flight requests when a user logs out.

Under the hood, NSOperationQueue leverages GCD, allowing it to take advantage of multiple cores without the developer needing to manage threads directly. By default, it will execute as many operations in parallel as the device can reasonably support.

NSOperation is an abstract class which needs to be subclassed to undertake a specific task. An NSOperation typically runs on a separate thread from the one that created it. Each operation is controlled via an internal state machine; the possible states are:

  • Pending indicates that the operation has been added to the queue.
  • Ready indicates that the operation is good to go, and if there is space on the queue, this operation's task can be started
  • Executing indicates that the operation is actually doing work at the moment.
  • Finished indicates that the operation has completed its task and should be removed from the queue.
  • Cancelled indicates that the operation has been cancelled and should stop its execution.

A typical operations lifecycle will move through the following states:

Operation state diagram showing an operations lifecycle going from Pending to Ready to Executing to Finished. It also shows how Pending, Ready and Executing can all end up in Cancelled

It's important to note that cancelling an executing operation will not automatically stop that operation; instead, it is up to the individual operation to clean up after itself and transition into the Finished state.

Operations come in two flavours:

  • Non-Concurrent
  • Concurrent

Non-Concurrent operations perform all their work on the same thread, so that when the main method returns, the operation is moved into the Finished state. The queue is then notified of this and removes the operation from its active operation pool, freeing resources for the next operation.

Concurrent operations can perform some of their work on a different thread, so returning from the main method can no longer be used to move the operation into a Finished state. Instead, when we create a concurrent operation, we assume the responsibility for moving the operation between the Ready, Executing, and Finished states.

This post will gradually build up to a working example. But if you can't wait, then head on over to the completed example to see how things end up.

Building Your Wingman

A networking operation is a specialised concurrent operation because when an NSURLSession makes a network request, it does so on a different thread from the thread that resumed that task. Rather than cramming everything into one operation, this solution will focus on building an abstract concurrent operation and then subclassing it to do the fetching and parsing in the subclass.

As mentioned, a concurrent operation takes responsibility for ensuring that its internal state is correct. This state is controlled by manipulating the isReady, isExecuting and isFinished properties. However, these are read-only, so these properties will need to be overridden so that we can set them. Let's start with isReady:

@interface NWMOperation : NSOperation

@end

@implementation NWMOperation

 // 1
@synthesize ready = _ready;

#pragma mark - Init

// 2
- (instancetype)init
{
    self = [super init];

    if (self)
    {
        self.ready = YES;
    }

    return self;
}

#pragma mark - State

// 3
- (void)setReady:(BOOL)ready
{
    if (_ready != ready)
    {
        [self willChangeValueForKey:NSStringFromSelector(@selector(isReady))];
        _ready = ready;
        [self didChangeValueForKey:NSStringFromSelector(@selector(isReady))];
    }
}

// 4
- (BOOL)isReady
{
    return [super isReady] && _ready;
}

@end

Here's what we did above:

  1. NSOperation declares ready as a read-only property. Overriding it with a custom setter and getter confuses the compiler's automatic ivar generation, so we need to explicitly synthesise the backing ivar _ready ourselves. Importantly, _ready is separate from the internal readiness state of NSOperation - @synthesize creates a new piece of storage that lives only in this subclass.
  2. _ready is set to YES at init, signalling that the operation has no internal reason to block execution. This is necessary because BOOL ivars default to NO, and nothing else in the class ever sets _ready back to YES. Without this, isReady would always return NO and the queue would never start the operation.
  3. The setter wraps the state change in KVO willChangeValueForKey: and didChangeValueForKey: calls. NSOperationQueue uses KVO to know when its operations change state, so without these notifications, the queue would never know the operation's readiness changed. The guard _ready != ready prevents redundant KVO notifications.
  4. [super isReady] and _ready are completely independent pieces of state. [super isReady] is managed internally by NSOperation and tracks things like whether dependencies have finished - we never write to it. _ready is managed by this subclass. By combining them, the operation is only considered ready when both the internal conditions of NSOperation are satisfied and the operation hasn't already started.

We then repeat this pattern with isExecuting and isFinished:

@implementation NWMOperation

// Omitted other functionality

@synthesize executing = _executing;
@synthesize finished = _finished;

// Omitted other functionality

- (void)setExecuting:(BOOL)executing
{
    if (_executing != executing)
    {
        [self willChangeValueForKey:NSStringFromSelector(@selector(isExecuting))];
        _executing = executing;
        [self didChangeValueForKey:NSStringFromSelector(@selector(isExecuting))];
    }
}

- (BOOL)isExecuting
{
    return _executing;
}

- (void)setFinished:(BOOL)finished
{
    if (_finished != finished)
    {
        [self willChangeValueForKey:NSStringFromSelector(@selector(isFinished))];
        _finished = finished;
        [self didChangeValueForKey:NSStringFromSelector(@selector(isFinished))];
    }
}

- (BOOL)isFinished
{
    return _finished;
}

@end

Note that we don't call super in isExecuting and isFinished.

Now that we have control of our operation's state, let's use that control by transiting from Ready -> Executing by overriding the start method:

@implementation NWMOperation

// Omitted other functionality

// 1
- (void)start
{
    // 2
    if (self.isCancelled)
    {
        // TODO: Finish this operation
        return;
    }

    // 3
    if (!self.isExecuting)
    {
        // 4
        self.ready = NO;
        self.executing = YES;
        self.finished = NO;
    }
}

@end

Here's what we did:

  1. start is called by the queue when the operation is ready for execution. By overriding start rather than main, this operation takes full control of its lifecycle - the queue will not automatically move it into the Finished state when the method returns. This is essential for concurrent operations where the real work (e.g. a network request) happens on a different thread and completes later.
  2. An operation can start in the cancelled state, so we check self.isCancelled, and if this operation has been cancelled, we finish this operation (will see that next).
  3. The !self.isExecuting check prevents the operation from being started twice. Without this, a second call to start would re-trigger KVO notifications and potentially confuse the queue.
  4. Update the isReady, isExecuting and isFinished states to ensure that the operation correctly represents its started nature.

Note that [super start] is intentionally not called. The default start implementation would call main and manage the state internally, which would conflict with the manual state management happening here. Subclasses of NWMOperation override start, and will call [super start] (which calls this implementation, not the implementation of NSOperation).

Let's fill in that TODO:

@interface NWMOperation : NSOperation

// 1
- (void)finish;

@end

@implementation NWMOperation

// Omitted other functionality

- (void)finish
{
    // 2
    if (self.executing)
    {
        // 3
        self.executing = NO;
        self.finished = YES;
    }
}

@end

Here's what we did:

  1. finish acts as a nice symmetrical opposite of the start method, ensuring that when an operation has finished, it is moved into the Finished state. It's essential that all operations eventually call this method. As it's to be called by subclasses of NWMOperation, we expose it in the header.
  2. The self.isExecuting check prevents the operation from being finished twice. Without this, a second call to finish would re-trigger KVO notifications and potentially confuse the queue.
  3. Update the isExecuting and isFinished states to ensure that the operation correctly represents its stated nature. There is no need to update isReady as this is already set to NO.

If you are experiencing odd behaviour where your queue seems to have jammed, and no operations are being processed, one of your operations is probably missing a finish call somewhere.

As operations can be cancelled, let's override cancel to call finish:

@implementation NWMOperation

// Omitted other functionality

- (void)cancel
{
    [super cancel];

    [self finish];
}

@end

It's important that regardless of how a concurrent operation ends, it moves into the Finished state.

All that's left to do is to override the isAsynchronous:

@implementation NWMOperation

- (BOOL)isAsynchronous
{
    return YES;
}

@end

As NWMOperation subclasses are always expected to be executed via an NSOperationQueue, overriding isAsynchronous is strictly not needed, but we override it here to express intent clearly.

isAsynchronous only matters if we call start directly on the operation without a queue. In that case, the caller is supposed to check isAsynchronous to decide whether to spin up a separate thread.

Armed with an asynchronous parent class, let's add subclasses that can make network requests to take advantage of this new functionality.

Giving Your Wingman a Target

Now that we've built the concurrent operation machinery piece by piece, we can see how little a concrete operation actually needs to add.

StackOverflow has an excellent, open API that we're going to use to build a networking operation.

This networking operation subclass will retrieve and parse the latest answers via the /answers endpoint.

The JSON structure of the /answers response and that response's subsequent parsing aren't shown below as they are not the central theme of this post (which is about the architecture of making a network request), but if you are interested in seeing everything working together, check out the example project.

Let's look at the complete operation and then examine πŸ‘€ the interesting parts:

// 1
@interface NWMAnswersRetrievalOperation : NWMOperation 

@end

// 2
@interface NWMAnswersRetrievalOperation ()

@property (nonatomic, copy) void (^completion)(NSArray *answers);

@end

@implementation NWMAnswersRetrievalOperation

#pragma mark - Init

// 3
- (instancetype)initWithCompletion:(void (^)(NSArray *answers))completion
{
    self = [super init];

    if (self)
    {
        self.completion = completion;
        self.name = @"Answers-Retrieval";
    }

    return self;
}

#pragma mark - Start

// 4
- (void)start
{
    [super start];

    NSURLSession *session = [NSURLSession sharedSession];

    NSURL *url = [NSURL URLWithString:@"https://api.stackexchange.com/2.2/answers?site=stackoverflow"];

    NSURLSessionDataTask *task = [session dataTaskWithURL:url
                                        completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error)
                                  {
                                      NSDictionary *answersJSON = [NSJSONSerialization JSONObjectWithData:data
                                                                                                  options:NSJSONReadingMutableContainers
                                                                                                    error:nil];

                                      NWMAnswerParser *parser = [[NWMAnswerParser alloc] init];
                                      NSArray *answers = [parser parseAnswers:answersJSON[@"items"]];

                                      if (self.completion)
                                      {
                                          self.completion(answers);
                                      }

                                      [self finish];
                                  }];

    [task resume];
}

@end

Here's what we did above:

  1. NWMAnswersRetrievalOperation subclasses NWMOperation, inheriting all the concurrent operation machinery - the manual state management, KVO notifications, and start/finish lifecycle. This means it only needs to focus on the specifics of its own network request and parsing.
  2. The class extension declares a private completion block property. This property is only set once during init and called once from the network response callback.
  3. Store the completion block and give the operation a human-readable name - the name is useful for debugging.
  4. start is where the actual work happens. [super start] is called first, which triggers NWMOperation's state transition from Ready to Executing. Then an NSURLSession data task is created and resumed. When the task completes on a background thread, the response is parsed into model objects via NWMAnswerParser, the completion block is called with the results, and [self finish] transitions the operation into the Finished state so the queue can reclaim the concurrency slot. The order matters - finish must be called after the completion block, otherwise the operation could be deallocated before the block executes.

Now that we have an operation making a network request, we need to schedule that operation.

The Support Crew

So far, 2 of the 3 responsibilities of the networking layer shown above have been built:

  • Performing networking requests
  • Parsing the response from network requests

Time to look at the final responsibility:

  • Scheduling network requests.

If we refer back to the class structure above, this responsibility is handled by both the APIManager and OperationQueueManager classes. The APIManager knows about a specific operation, and the OperationQueueManager knows how to schedule an operation on its queue. Let's start with the OperationQueueManager:

@interface NWMOperationQueueManager : NSObject

// 1
+ (instancetype)sharedInstance;

// 2
- (void)addOperation:(NSOperation *)operation;
@interface NWMOperationQueueManager ()

// 3
@property (nonatomic, strong) NSOperationQueue *queue;

@end

@implementation NWMOperationQueueManager

#pragma mark - SharedInstance

+ (instancetype)sharedInstance
{
    static NWMOperationQueueManager *sharedInstance = nil;

    // 4
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^
                  {
                      sharedInstance = [[NWMOperationQueueManager alloc] init];
                  });

    return sharedInstance;
}

#pragma mark - Init

- (instancetype)init
{
    self = [super init];

    if (self)
    {
        self.queue = [[NSOperationQueue alloc] init];
    }

    return self;
}

#pragma mark - AddOperation

- (void)addOperation:(NSOperation *)operation
{
    [self.queue addOperation:operation];
}

@end

Here's what we did:

  1. NWMOperationQueueManager is a singleton. Using a singleton means all operations across the app are funnelled through the same manager, giving us a single point of control.
  2. addOperation: is the only way to schedule work through this manager. It accepts any NSOperation subclass, which keeps the interface generic - the manager doesn't need to know about NWMAnswersRetrievalOperation or any other concrete operation type.
  3. The class extension declares a private NSOperationQueue. By hiding the queue behind addOperation:, external code can never directly manipulate it - they can't pause it, change its concurrency limits, or remove operations. This encapsulation also makes it straightforward to expand later, for example, adding a second queue for system events versus user-driven events, with each APIManager specifying which queue to target.
  4. The dispatch_once block ensures the shared instance is only created once, even if multiple threads call sharedInstance simultaneously.

With an operation queue to schedule work on, let's build the APIManager:

@interface NWMAnswersAPIManager : NSObject

// 1
+ (void)retrieveAnswersWithCompletion:(void (^)(NSArray *answers))completion;

@end
@implementation NWMAnswersAPIManager

#pragma mark - Retrieval

+ (void)retrieveAnswersWithCompletion:(void (^)(NSArray *answers))completion
{
    // 2
    NWMAnswersRetrievalOperation *operation = [[NWMAnswersRetrievalOperation alloc] initWithCompletion:completion];

    [[NWMOperationQueueManager sharedInstance] addOperation:operation];
}

@end

Here's what we did above:

  1. retrieveAnswersWithCompletion: is a class method (+), so callers don't need to create an instance of the manager. Using a class method keeps view controller code clean - no manager instances to store as properties or manage lifecycle for. The completion block follows the same pattern as the operation, returning an NSArray of parsed answers or nil.
  2. Creates a configured NWMAnswersRetrievalOperation with the caller's completion block, and schedules it on the shared NWMOperationQueueManager. The caller never needs to know that operations or queues exist - they ask for answers and get a callback. The boundary between feature and fetching isolates both parts and helps to ensure clear responsibilities.

And with that, all three responsibilities are now in place! πŸͺ‡

Going Home

In some ways, we got lucky that NSURLConnection was hard to use. That difficulty forced us to build structure around our networking code, and that structure led us to make smarter decisions about where functionality lived.

When NSURLSession removed the difficulty, the structure went with it.

Building a networking layer around NSOperation isn't about making things harder again. It's about restoring the boundaries that keep responsibilities clear.

What do you think? Let me know by getting in touch on Mastodon or Bluesky.