Objective-C

Message Dynamic Resolution: The Complete Execution Flow — Three Chances to Recover

By Seren  |  14 Sep, 2026  |  1 Comment


I first used Objective-C message forwarding in a production analytics migration, not in a runtime experiment. An older part of the app still sent legacyTrack:payload:, while the replacement service exposed recordEvent:payload:. Rewriting every caller in one release was risky, and silently dropping checkout events was not acceptable. I built a small compatibility layer, then tested each forwarding stage separately so I knew exactly where a missing selector was being recovered.

That project also changed how I explain the topic. Objective-C does not simply give an object “three chances before a crash” in one interchangeable list. Dynamic resolution, fast forwarding, and full forwarding happen at different points, have different costs, and solve different design problems. Choosing the earliest hook is useful only when that hook actually matches the job.

The Bug That Led Me to the Forwarding Path

The migration bug appeared only in an older checkout screen. New screens called the new analytics service directly, but the old controller still sent a selector that no longer existed on its adapter. The result was the familiar unrecognized selector sent to instance exception. My first instinct was to add a placeholder method and move on. That stopped the crash, but it scattered compatibility code through a class that was supposed to disappear later.

I instead made one adapter responsible for the old API. The important lesson was not that forwarding can hide any missing method. It was that a narrow forwarding boundary can keep a staged migration explicit. The adapter accepted only known selectors, forwarded them to a real service, and allowed every unexpected selector to continue through the normal failure path. Swallowing arbitrary messages would have hidden programming errors.

What Happens After Normal Lookup Fails

An Objective-C call such as [receiver recordEvent:name] is message sending. At a high level, the runtime uses the receiver and selector to locate an implementation in the class hierarchy. A cached implementation can make a repeated lookup fast. When no implementation is found, the runtime may ask the class to resolve the method and, if it remains unresolved, Foundation’s forwarding machinery gives the object opportunities to redirect or represent the message.

I avoid treating private runtime functions, cache layouts, or assembly branches as application contracts. They are valuable when debugging a specific OS release, but the public hooks are the stable part of the design. It is also important to remember that sending a message to nil is a separate behavior; it does not enter this recovery sequence as a normal missing-method crash.

First Stage: Installing a Known Legacy Method

The first adapter in the demo handles legacyTrack:payload: through +resolveInstanceMethod:. When the selector is requested for the first time, the class adds a C function as its implementation. The runtime can then retry normal lookup. This is appropriate when the receiver itself should permanently gain behavior for a predictable selector.

static void LegacyTrackIMP(id self, SEL _cmd,
                           NSString *name, NSDictionary *payload) {
    (void)_cmd;
    EventStore *store = [(DynamicAdapter *)self store];
    [store recordEvent:name payload:payload];
}

+ (BOOL)resolveInstanceMethod:(SEL)selector {
    if (selector == NSSelectorFromString(@"legacyTrack:payload:")) {
        return class_addMethod(self, selector,
                               (IMP)LegacyTrackIMP, "v@:@@");
    }
    return [super resolveInstanceMethod:selector];
}

The type encoding must match the real function signature. Here, v@:@@ describes a void return value, the hidden self and _cmd arguments, and two object arguments. A mismatched encoding or function pointer is not a harmless shortcut; it can produce undefined behavior. I keep dynamically installed methods small, named in one place, and covered by an executable test.

Second Stage: Redirecting to the Real Service

In the second adapter, the selector already has the correct name and signature. The adapter does not need to own an implementation at all. Its -forwardingTargetForSelector: method returns the EventStore, and the original message is sent to that object. This kept the compatibility object thin and made the real service responsible for the behavior.

- (id)forwardingTargetForSelector:(SEL)selector {
    if (selector == @selector(recordEvent:payload:)) {
        return self.store;
    }
    return [super forwardingTargetForSelector:selector];
}

This hook is commonly called fast forwarding because it avoids creating and manipulating an NSInvocation. I do not attach an unsupported “orders of magnitude faster” number to it; the actual cost depends on the workload and platform. The useful engineering distinction is simpler: use it when the entire message can be handled unchanged by another object. Never return self, and do not create a new target on every call unless that lifetime is intentional.

Third Stage: Translating the Message with NSInvocation

The final adapter receives sendEvent:context:, but the service understands recordEvent:payload:. A direct redirect is not enough because the selector must change. The adapter first returns a compatible method signature. Foundation can then construct an NSInvocation, which the adapter retargets in -forwardInvocation:.

- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector {
    if (selector == NSSelectorFromString(@"sendEvent:context:")) {
        return [self.store methodSignatureForSelector:
                @selector(recordEvent:payload:)];
    }
    return [super methodSignatureForSelector:selector];
}

- (void)forwardInvocation:(NSInvocation *)invocation {
    if (invocation.selector == NSSelectorFromString(@"sendEvent:context:")) {
        invocation.selector = @selector(recordEvent:payload:);
        [invocation invokeWithTarget:self.store];
        return;
    }
    [super forwardInvocation:invocation];
}

The method signature is essential because it tells NSInvocation how the return value and arguments are represented. Returning a made-up signature just to suppress a crash is dangerous. If the adapter does not recognize the selector, it calls super. That preserves the normal doesNotRecognizeSelector: behavior instead of converting a real defect into missing data.

How I Decide Which Stage to Use

  • Use dynamic resolution when a known method should become real behavior on the receiving class.
  • Use fast forwarding when another existing object can handle the exact message unchanged.
  • Use full forwarding when the selector, target, arguments, or dispatch decision must be inspected or adapted.
  • Use an ordinary method when the behavior is known at compile time and no compatibility boundary is needed.

In production I also keep the allowed selector list explicit. Broad forwarding proxies are harder to audit, and methods such as respondsToSelector: may need deliberate handling when other code performs capability checks. Forwarding is not a general crash-prevention system. It should not catch typos, conceal removed APIs forever, or make an object pretend to support behavior it cannot complete correctly.

Run the Complete Project

The companion repository is a Foundation-only macOS command-line project. It sends one event through each recovery stage and verifies the value stored by the final service. The test exits immediately with a non-zero status if a stage records the wrong result, so the example is more useful than a collection of disconnected snippets.

git clone https://github.com/2252408699/objc-message-forwarding-recovery-demo.git
cd objc-message-forwarding-recovery-demo
make run

A successful run prints four PASS lines and ends with All forwarding checks passed. The project requires macOS and the Xcode Command Line Tools, but it does not require an iOS simulator, CocoaPods, or any third-party library. Run make clean to remove the executable.

What I Would Check Before Shipping This Pattern

Before shipping a forwarding adapter, I test the supported selector, an unsupported selector, argument ownership, return types, and the lifetime of the forwarding target. I add logging during the migration so old call sites can be counted and removed later. I also give the compatibility layer an expiration plan. A bridge that was meant to survive two releases can quietly become permanent architecture if nobody measures its use.

Complete source code: objc-message-forwarding-recovery-demo on GitHub

Seren
Seren

A developer exploring iOS, Objective-C, Swift, and other tech stacks. Here to document my learning process, pitfalls, and growing journey across new technologies.

Your email address will not be published. Required fields are marked *

1 Comment