Protocol-Oriented APIs

Understand the architectural reasons protocols are central to well-designed Objective-C code, including dependency injection, mocking, abstraction, delegation, and testability.

Once you understand what a protocol is, the more important question is why experienced Objective-C developers reach for one so often. The answer is architectural: protocols let code depend on capabilities instead of concrete classes, and that single shift is what makes large codebases easier to test, easier to extend, and easier to change without a cascade of edits across unrelated files.

Programming to an interface, not an implementation

The core idea behind protocol-oriented design predates Objective-C — it is the classic object-oriented principle "program to an interface, not an implementation," and protocols are exactly the mechanism Objective-C provides for it. When a method's parameter type is a concrete class like NetworkManager *, every caller is locked into that exact class and every future substitute has to literally be (or subclass) NetworkManager. When the parameter type is instead id<DataFetching>, any object of any class that conforms to DataFetching can be passed in.

This one change ripples outward. Code that accepts protocol-typed parameters is inherently more reusable, because it makes no assumption about where the object came from or what else it can do — only that it satisfies the specific contract the code actually needs. It is also more honest: the method signature itself documents exactly what capability is required, rather than forcing a reader to go look at NetworkManager's full public interface to guess which parts are actually used.

Dependency injection

Dependency injection means a class receives (is "injected" with") the objects it depends on from the outside, rather than constructing them itself internally. Protocols make this pattern natural in Objective-C: instead of a ReportGenerator hard-coding NetworkManager *fetcher = [[NetworkManager alloc] init]; inside its own initializer, it declares a property or initializer parameter typed id<DataFetching> fetcher and lets whoever creates the ReportGenerator decide what concrete object to hand it.

The practical payoff is substitutability. In production, the app hands ReportGenerator a real NetworkManager that talks to the live API. In a unit test, the test hands it a lightweight fake object that returns canned data instantly, with no network involved. In a future version of the app, an entirely different CachingDataFetcher class can be substituted with zero changes to ReportGenerator itself, because ReportGenerator never knew or cared which concrete class it was talking to — only that it conformed to DataFetching.

Without protocols, achieving the same substitutability in Objective-C usually means subclassing the concrete class and overriding methods, which is fragile (it depends on implementation details of the class being subclassed) and often impossible (many Cocoa classes are not designed to be subclassed safely). Protocol-typed dependencies sidestep the problem entirely.

Mocking for unit tests

Mocking is the practice of substituting a lightweight, controllable stand-in for a real object during a test, so the test can exercise your code's logic without depending on a real network connection, database, or file system. Protocols are what make clean mocks possible in Objective-C: if your code depends on id<DataFetching> rather than the concrete NetworkManager class, a test can create a MockDataFetcher class that also conforms to DataFetching and returns whatever fixed response the test wants — a successful payload, an error, an empty result — deterministically and instantly.

Because the test double conforms to exactly the same protocol the production code depends on, the code under test cannot tell the difference between the real fetcher and the mock — it only ever calls the methods the protocol promises. This is what makes protocol-oriented code so much easier to unit test than code wired directly to concrete framework classes: those tests would otherwise need a real (or heavily stubbed-out) network stack just to exercise a single method's logic.

A well-designed mock typically also records what was called and with what arguments, so a test can assert not only that ReportGenerator produced the right output, but that it called fetchData: exactly once with the expected parameters — verification that would be far harder to arrange against a real NetworkManager.

Abstraction

Abstraction, in this context, means hiding the details of how something is done behind a description of what can be done. A protocol is Objective-C's purest tool for abstraction: id<DataFetching> describes a capability without exposing anything about HTTP, caching, retries, file I/O, or whatever else a particular conforming class does internally to fulfill that capability.

This decouples callers from concrete types across the whole codebase. Code that only ever interacts with id<Printable>, id<DataFetching>, or id<DownloadDelegate> has no compile-time dependency on Report, NetworkManager, or ViewController at all — it can be compiled, reasoned about, and reused without ever importing those headers. As a codebase grows, this kind of abstraction is what keeps changes local: swapping out NetworkManager's internals, or replacing it entirely, touches only the class itself and whatever assembles the dependency graph, not every piece of code that happens to fetch data.

Delegation as protocol-oriented design

The delegate pattern covered in the previous lesson is itself a specific application of everything discussed here — dependency injection, abstraction, and substitutability, applied to the specific problem of one object notifying another about events. A Downloader's delegate property is a dependency, injected from the outside, typed abstractly as id<DownloadDelegate> rather than as any concrete class, which is exactly what makes it possible to swap in a ViewController today and a Logger or a test double tomorrow without changing Downloader at all.

Seeing delegation as a special case of protocol-oriented design (rather than as an unrelated pattern to memorize separately) makes the rest of Cocoa's API surface much easier to read: every *Delegate and *DataSource protocol you encounter — UITableViewDataSource, UIScrollViewDelegate, NSTextViewDelegate — is the framework author applying the same dependency-injection and abstraction principles to their own object graph.

Testability as the payoff

Pulling all of the previous points together: the reason experienced Objective-C developers reach for protocols so readily is that they make code testable almost as a side effect of making it well-designed. A class whose dependencies are all protocol-typed can have every one of those dependencies replaced with a test double, which means its own logic — the part actually worth testing — can be exercised in isolation, quickly and deterministically, without spinning up a server, a database, or the file system.

The inverse is also true and worth internalizing: code that is hard to unit test is very often code that depends directly on concrete classes instead of protocols. When you find yourself needing to write an elaborate test harness just to exercise one method, it is usually a sign that a dependency which should have been expressed as id<SomeProtocol> was instead hard-wired to a specific class, and introducing a protocol at that seam is frequently the fix.

Code Examples

ReportGenerator never mentions NetworkManager or any other concrete class — its initializer accepts anything conforming to DataFetching. Whoever constructs a ReportGenerator decides what real object to inject, which is the essence of dependency injection. In production code you might write [[ReportGenerator alloc] initWithFetcher:[[NetworkManager alloc] init]]; a test would instead pass a mock.

• (instancetype)initWithFetcher:(id<DataFetching>)fetcher; injects the dependency from outside rather than letting ReportGenerator construct it internally.

MockDataFetcher conforms to the exact same DataFetching protocol as the real NetworkManager, so ReportGenerator cannot tell them apart. The mock returns fixed fake data instantly and records that it was called, letting the test assert on ReportGenerator's behavior without any real networking. Expected result: Report generated from 12 bytes

ReportGenerator *generator = [[ReportGenerator alloc] initWithFetcher:mock]; substitutes the mock in place of a real network-backed fetcher purely because both conform to DataFetching.

Common mistake: Writing tests against NetworkManager directly (instead of against id<DataFetching>) would force every test to perform a real network call, making tests slow, flaky, and dependent on network availability.

NetworkManager and CachingDataFetcher are two entirely different implementations of the same abstraction, DataFetching. CachingDataFetcher even composes another id<DataFetching> internally, demonstrating how far protocol-oriented abstraction can be layered: any code depending on id<DataFetching> works unchanged with either one, or with a chain of them. This decorator-like structure — wrapping one conformer around another — is only possible because both sides agree on nothing more than the protocol.

@property (nonatomic, strong) id<DataFetching> underlyingFetcher; shows a class depending on the same abstraction it also implements, enabling composition.

@protocol DataFetching <NSObject>
- (void)fetchDataWithCompletion:(void (^)(NSData *data, NSError *error))completion;
@end

@interface ReportGenerator : NSObject
@property (nonatomic, strong) id<DataFetching> fetcher;
- (instancetype)initWithFetcher:(id<DataFetching>)fetcher;
- (void)generateReport;
@end

@implementation ReportGenerator

- (instancetype)initWithFetcher:(id<DataFetching>)fetcher {
    self = [super init];
    if (self) {
        _fetcher = fetcher;
    }
    return self;
}

- (void)generateReport {
    [self.fetcher fetchDataWithCompletion:^(NSData *data, NSError *error) {
        if (error) {
            NSLog(@"Report failed: %@", error);
            return;
        }
        NSLog(@"Report generated from %lu bytes", (unsigned long)data.length);
    }];
}

@end
@interface MockDataFetcher : NSObject <DataFetching>
@property (nonatomic, assign) BOOL wasCalled;
@end

@implementation MockDataFetcher

- (void)fetchDataWithCompletion:(void (^)(NSData *data, NSError *error))completion {
    self.wasCalled = YES;
    NSData *fakeData = [@"fake payload" dataUsingEncoding:NSUTF8StringEncoding];
    completion(fakeData, nil);
}

@end

// In a unit test:
MockDataFetcher *mock = [[MockDataFetcher alloc] init];
ReportGenerator *generator = [[ReportGenerator alloc] initWithFetcher:mock];
[generator generateReport];
NSAssert(mock.wasCalled, @"Expected fetchDataWithCompletion: to be called");
@interface NetworkManager : NSObject <DataFetching>
@end

@implementation NetworkManager
- (void)fetchDataWithCompletion:(void (^)(NSData *, NSError *))completion {
    // real URLSession-based network call here
}
@end

@interface CachingDataFetcher : NSObject <DataFetching>
@property (nonatomic, strong) id<DataFetching> underlyingFetcher;
@end

@implementation CachingDataFetcher
- (void)fetchDataWithCompletion:(void (^)(NSData *, NSError *))completion {
    NSData *cached = [self cachedData];
    if (cached) {
        completion(cached, nil);
        return;
    }
    [self.underlyingFetcher fetchDataWithCompletion:completion];
}
- (NSData *)cachedData { return nil; }
@end

One protocol, many interchangeable conformers

ReportGenerator's dependency is expressed entirely in terms of the protocol, so production, caching, and test implementations are all interchangeable.

id<DataFetching>
                        |
        +---------------+----------------+
        |               |                |
  NetworkManager   CachingDataFetcher  MockDataFetcher
  (real network)   (wraps another      (returns fake
                    DataFetching)        data instantly)

   ReportGenerator depends only on id<DataFetching> —
   it can be handed any of the three above unchanged.

Important Rules

Depending on id<Protocol> instead of a concrete class lets any conforming object be substituted without changing the dependent code.

Dependency injection means a class receives its dependencies from outside (an initializer or property) rather than constructing them itself.

A mock only needs to conform to the same protocol the production code depends on to be usable as a drop-in test double.

Protocols abstract what an object can do from how it does it, keeping callers decoupled from concrete implementation details.

The delegate pattern is a specific, very common application of protocol-oriented dependency injection and abstraction.

Code that is hard to unit test is frequently code that depends on concrete classes where a protocol should have been used instead.

Where this shows up

Large Objective-C codebases, including most of Apple's own frameworks, lean on protocol-typed dependencies to keep components independently testable and swappable — URLSession-based networking can be swapped for offline fixtures in tests, view controllers can be tested against mock data sources, and entire subsystems can be replaced behind a stable protocol without touching the code that depends on them.

Typing a dependency as a concrete class, e.g. @property (nonatomic, strong) NetworkManager *fetcher;

Type it by protocol instead: @property (nonatomic, strong) id<DataFetching> fetcher;

A concrete-class-typed property locks every caller into that exact class, making it impossible to inject a mock or an alternative implementation without subclassing or modifying the concrete class.

Constructing a dependency inside a class's own initializer, e.g. self.fetcher = [[NetworkManager alloc] init]; inside ReportGenerator's init.

Accept the dependency as an initializer parameter or injected property so the caller controls what gets used: initWithFetcher:(id<DataFetching>)fetcher

Constructing dependencies internally hard-wires the class to one concrete implementation, defeating dependency injection and making the class untestable in isolation.

Writing a unit test that exercises real networking because the code under test depends directly on NetworkManager.

Depend on id<DataFetching> in the code under test, and substitute a MockDataFetcher conforming to the same protocol in the test.

Tests that require a live network call are slow, flaky, and depend on external services being available — the whole point of mocking through a protocol is to avoid this.

In dependency injection, where does a class get the objects it depends on? A. It always constructs them itself inside its own initializer B. They are provided from outside, typically through an initializer parameter or a settable property C. They are generated automatically by the Objective-C runtime D. They must be global singletons

B. They are provided from outside, typically through an initializer parameter or a settable property — Dependency injection means dependencies are handed to a class from the outside rather than being constructed internally, which is what makes substitution possible.

Why can a MockDataFetcher be substituted for a real NetworkManager in a unit test without changing the code under test? A. Because MockDataFetcher is a subclass of NetworkManager B. Because both classes happen to have the same name for their methods, by coincidence C. Because both conform to the same protocol that the code under test depends on D. Because Objective-C automatically converts any object into a mock during testing

C. Because both conform to the same protocol that the code under test depends on — The code under test depends on the protocol type (id<DataFetching>), not on any concrete class, so any conformer — real or mock — is interchangeable.

What does abstraction, as protocols provide it, primarily hide from a caller? A. The name of the method being called B. How a capability is implemented, exposing only what can be done C. The number of parameters a method takes D. Whether the object is nil or not

B. How a capability is implemented, exposing only what can be done — A protocol exposes a capability's surface (what methods exist) while hiding the concrete implementation behind it, decoupling callers from implementation details.

Why is the delegate pattern described as a specific application of protocol-oriented design? A. Because delegates must always be singletons B. Because it applies dependency injection and abstraction to the specific problem of one object notifying another about events C. Because delegate methods cannot be optional D. Because delegation only works with UIKit classes

B. Because it applies dependency injection and abstraction to the specific problem of one object notifying another about events — A delegate property is an injected, protocol-typed dependency — exactly the same pattern used for testability and abstraction elsewhere, applied to cross-object event notification.

What is usually the underlying cause when a piece of Objective-C code is hard to unit test? A. The code uses too many comments B. The code depends directly on concrete classes where a protocol-typed dependency should have been used C. The code was written in a .m file instead of a .h file D. The code does not use categories

B. The code depends directly on concrete classes where a protocol-typed dependency should have been used — Hard-to-test code is frequently wired directly to concrete classes (like a real networking class); introducing a protocol at that dependency seam typically restores testability by allowing a mock to be substituted.

Define a protocol Logging with a required method logMessage:. Write a class ConsoleLogger that conforms to it by printing with NSLog, and a class MockLogger that conforms to it by storing messages in an NSMutableArray property instead. Then write a class UserSession that depends on id<Logging> injected through its initializer and calls logMessage: when a user logs in.

@protocol Logging <NSObject> - (void)logMessage:(NSString *)message; @end @interface ConsoleLogger : NSObject <Logging> @end @implementation ConsoleLogger - (void)logMessage:(NSString *)message { NSLog(@"%@", message); } @end @interface MockLogger : NSObject <Logging> @property (nonatomic, strong) NSMutableArray<NSString *> *messages; @end @implementation MockLogger - (void)logMessage:(NSString *)message { if (!self.messages) self.messages = [NSMutableArray array]; [self.messages addObject:message]; } @end @interface UserSession : NSObject @property (nonatomic, strong) id<Logging> logger; - (instancetype)initWithLogger:(id<Logging>)logger; - (void)login; @end @implementation UserSession - (instancetype)initWithLogger:(id<Logging>)logger { self = [super init]; if (self) { _logger = logger; } return self; } - (void)login { [self.logger logMessage:@"User logged in"]; } @end

UserSession should never reference ConsoleLogger or MockLogger by name — only id<Logging>.