I used to think Objective-C message forwarding was mainly something to study for interviews. That changed during a checkout-module migration. We had an old analytics selector, a receipt exporter hidden behind a compatibility object, and two components that both needed an order-completion callback. All three problems involved a message the receiving object did not implement, but they did not deserve the same solution.
I built a small test project before changing the production code. It let me compare dynamic method resolution, fast forwarding, and full forwarding without mixing them into one oversized proxy. The result was less magical than the usual “three chances to avoid a crash” explanation: each stage is useful when its responsibility is narrow, explicit, and testable.
The Checkout Migration That Needed Three Different Tools
The old checkout controller still called trackLegacyOrder:. The new receipt component called exportReceipt:, but the compatibility facade did not own that implementation. After payment completed, both an audit component and a UI component needed orderDidFinish:. Replacing every caller in one release would have made the rollout harder to reverse, so the migration layer stayed for a short transition period.
My first draft put every unknown selector into forwardInvocation:. It worked in a demo, but it made unrelated failures look valid. A typo could travel through the proxy before finally failing, and it was difficult to see which methods were intentionally supported. I split the behavior by scenario and kept a strict selector allowlist in every hook.
Stage One: Add One Predictable Legacy Method
Dynamic method resolution fits the analytics call because the tracker itself should handle that known selector. When lookup first fails, +resolveInstanceMethod: can install a matching implementation. After that, the method behaves like part of the class. I use this only for selectors whose signatures and behavior are known in advance.
static void TrackLegacyOrder(id self, SEL _cmd, NSString *orderID) {
(void)_cmd;
[[(CheckoutTracker *)self events] addObject:orderID];
}
+ (BOOL)resolveInstanceMethod:(SEL)selector {
if (selector == NSSelectorFromString(@"trackLegacyOrder:")) {
return class_addMethod(self, selector,
(IMP)TrackLegacyOrder, "v@:@");
}
return [super resolveInstanceMethod:selector];
}
The type encoding and C function signature must agree. Here, the method returns void and receives one object argument in addition to the hidden self and _cmd parameters. I would not generate encodings from untrusted server data or install arbitrary method names. Runtime flexibility does not remove the need for an API contract.
Stage Two: Route an Unchanged Call to Its Real Owner
The receipt case is simpler. ReceiptExporter already implements the exact selector with the correct arguments. The compatibility facade only needs to return that existing object from -forwardingTargetForSelector:. There is no reason to create an NSInvocation or duplicate the export code.
- (id)forwardingTargetForSelector:(SEL)selector {
if (selector == @selector(exportReceipt:)) {
return self.exporter;
}
return [super forwardingTargetForSelector:selector];
}
This is message routing, not crash prevention. Returning nil does not safely absorb an unknown message; it allows forwarding to continue and can still end in doesNotRecognizeSelector:. Returning self is also wrong because it cannot resolve the missing implementation. The target should be a stable object that genuinely supports the selector.
Stage Three: Broadcast a Void Notification Carefully
Full forwarding is justified when one message needs inspection or custom delivery. In this project, orderDidFinish: goes to both an audit observer and a UI observer. The proxy asks its observers for a valid method signature, then invokes the message on each object that declares support.
- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector {
for (id observer in self.observers) {
NSMethodSignature *signature =
[observer methodSignatureForSelector:selector];
if (signature) { return signature; }
}
return [super methodSignatureForSelector:selector];
}
- (void)forwardInvocation:(NSInvocation *)invocation {
BOOL delivered = NO;
for (id observer in self.observers) {
if ([observer respondsToSelector:invocation.selector]) {
[invocation invokeWithTarget:observer];
delivered = YES;
}
}
if (!delivered) { [super forwardInvocation:invocation]; }
}
I deliberately use a void notification here. Broadcasting a method with a meaningful return value creates an unanswered question: which observer’s result should the caller receive? Argument mutation, exceptions, observer removal, and asynchronous delivery also require explicit rules. For a production delegate multiplexer, I would document those semantics rather than assuming repeated invocation is automatically correct.
Why I Removed the NSNull Swizzling Approach
The earlier version of this article suggested globally replacing forwarding behavior on NSNull and silently dropping unknown messages. I would not ship that. A dummy method signature may not match the caller’s expected return type or arguments, and globally modifying a Foundation class affects code far beyond the original JSON field. It can turn a clear data-contract bug into quiet corruption.
I handle JSON nullability at the decoding boundary instead. Validate the expected type, translate NSNull into an optional value, and record malformed responses where they enter the system. An array out-of-bounds exception is also not an unrecognized-selector problem, so message forwarding cannot replace an index check. Recovery code should match the failure it claims to solve.
Where Ordinary Composition Is Better
Most application code does not need runtime forwarding. A facade can expose a normal method and call its collaborator directly. A notification center, delegate collection, or typed event bus may communicate intent more clearly than an NSInvocation proxy. I choose forwarding when it protects a well-defined compatibility boundary or implements proxy behavior that callers already expect.
- Prefer a normal method when the call is known at compile time.
- Allow only selectors the adapter intentionally supports.
- Keep method signatures identical when redirecting a message unchanged.
- Define return-value and exception behavior before broadcasting.
- Let unsupported selectors fail normally instead of swallowing them.
- Add tests and a removal date to temporary migration layers.
Run the Complete Foundation Demo
The companion project uses only Foundation and the public Objective-C runtime APIs. It sends one order through the dynamically added tracking method, routes a receipt export through fast forwarding, and broadcasts order completion to two observers. Each step checks the stored result and exits with status one if anything is wrong.
git clone https://github.com/2252408699/objc-forwarding-application-scenarios-demo.git
cd objc-forwarding-application-scenarios-demo
make run
A successful run prints four PASS lines and ends with All application-scenario checks passed. It requires macOS and the Xcode Command Line Tools, but no simulator or third-party dependency. Use make clean to remove the executable.
The Review Checklist I Use Now
In code review I ask whether the selector list is finite, whether the target lifetime is clear, whether respondsToSelector: must reflect forwarded capabilities, and whether an ordinary protocol would be easier to understand. I also test an unsupported selector. That negative test proves the proxy has not become a blanket error suppressor.
Complete source code: objc-forwarding-application-scenarios-demo on GitHub
