Rewriting iOS Purchase Layer for #1 Top Grossing App using Modules
Written by: Steven Chung, Senior iOS Engineer | Revenue Team
About the Tinder application
Up until recently, the Tinder iOS app was a monolith — all of the code was contained in a single build target. When Tinder was a smaller startup, the iOS codebase wasn’t large. Having a monolithic codebase was fine then since it allowed quicker iterations and faster feature rollouts. However, as time progressed, introducing new code into the project became less manageable. During development, a single line change could trigger a full recompilation of the app, taking upwards of 15 minutes to complete. Working with a team of dozens of iOS engineers, the long compile times were multiplied across the organization and it was clear that this was a pain point in the development cycle that needed to be addressed.
The revenue team owns all of the code responsible for providing a premium experience for subscribers and users who choose to purchase a la carte features. To find the most optimal user experience, we frequently perform various experiments to dictate what works best for our paying users. One of these areas is the purchase layer, which contains the logic responsible for allowing users to make in-app purchases. We’ve repeatedly encountered scenarios where making changes to the purchase layer would cause the entire app to be recompiled. The purchase layer serves a specific purpose and has a well defined set of responsibilities, so why should a change in the purchase layer trigger a recompilation of irrelevant code?
The answer is as you might suspect — it shouldn’t have to.
How do modules help?
The answer is to separate the codebase into frameworks or modules. A module is encapsulated code that can be compiled and run independently of the main app. There are a number of different ways to modularize your app, and the new purchase layer of our app, called PurchaseService, was configured as a dynamic framework managed through Cocoapods.
Perhaps the most attractive use-case of utilizing modules for us was the drastic improvement in compile times, but leveraging modules allows for several other benefits. These benefits include flexibility in architectural design, finer grained access control, the inherent abstraction of dependencies, ability to attain higher test coverage, containment of relevant code all in a single place, and an opportunity to rewrite legacy Objective-C code into Swift.
The first challenge you may encounter during the module port-over process is how to effectively move code from the main target to the new framework. As with good practice, we wanted to keep our pull request sizes as small as possible, so merging in all of the new changes at once wasn’t a viable option. More importantly, we needed to ensure that the existing code continued to work seamlessly as new builds were being shipped during the rewrite process.
In order to protect these entry points to this half-baked code, we added feature flags that the server would enable only to clients with the completed code when we were ready to release. This ensured that the new changes would not disturb the existing code while newer versions of the app were shipped, and allowed us to later use the same flag to A/B test the two libraries and ensure no negative impact on key purchase metrics.
Our premium offerings are configured as in-app products on App Store Connect. The purchase layer of an iOS app consists of three main flows: requesting products, purchasing products, and verifying those purchases.
Below is an implementation for requesting products where the use case was similar to our Objective-C legacy code. It has been rewritten in Swift and does not contain any logic around caching or multiple product request handling for demonstration purposes.
This implementation may work fine when working with a monolithic project, but it poses several problems when migrating to the new PurchaseService module. Most notably, it exposes StoreKit entities, requiring PurchaseService consumers to also import the underlying StoreKit dependency. Ideally, all the consumer needs to do is import the new PurchaseService module without having to ever touch StoreKit classes directly. To address this issue, we created simple wrapper objects in lieu of StoreKit objects.
These wrapper objects are passed back from the PurchaseService so that the consumer has no knowledge about the underlying StoreKit dependency. We can see how abstracting out dependencies and exposing them as protocols helps to maintain separation of concerns in the appropriate layers of the application.
In order to make a payment, we must add the payment to StoreKit’s SKPaymentQueue class for processing. For similar reasons as those above, we’ve abstracted away the SKPaymentQueue dependency and exposed it via a protocol. AppleQueueProcessor serves as the wrapper against SKPaymentQueue and implements the QueueProcessor protocol.
Let’s consider the specific case where the user’s device is ineligible to make payments. We want to assert that the product is not added to the queue, and that the purchase method calls back with the appropriate error.
We want to test this code, but we don’t want to make actual purchases during testing. Even if we were to make actual purchases to test our code, it would be difficult to simulate all of the possible combinations of responses. To account for this, we can utilize the protocol abstractions to create a mock queue processor in lieu of SKPaymentQueue. The default implementation for all of the functions result in assertion failures, and each test should override the implementation if they expect a certain method to be called.
We want to test the case where the device does not support payments, so we set the canMakePayments flag to false and assert the result against the expected values.
This is one of the many test cases around the purchasing code. Although each test case is different, the same techniques can be applied to mock scenarios, even complex ones, and assert against the expected result. Creating our own test queue allows us to mock Apple’s SKPaymentQueue dependency in order to achieve comprehensive code coverage.
Verifying a user’s purchase is perhaps the most complex of the three flows since there’s flexibility in the implementation. Receipt verification can be done either client-side or server-side, and the server contract can vary. In order to support all of the different use cases, the receipt verification logic is injected into the PurchaseService via the constructor. Tinder uses the backend to verify receipts for in-app purchases, and the integration with the PurchaseService module at a high-level looks like the following:
The Tinder app utilizes a PurchasableProduct class, that augments SKProduct and represents an in-app purchase. This class contains important metadata (subscription duration, consumable amount, consumable type, etc.) and is passed around to other classes in the main Tinder app. We needed the PurchasableProduct class to be serialized after the receipt verification flow, and we needed a way for the PurchaseService module to know about this class without having it as a concrete dependency. We utilized generics to get around the PurchaseService module to be able to pass back the appropriate type without knowing about any Tinder-specific classes.
After verifying the receipt, the main Tinder app would be passed back a set of PurchasableProducts. Injecting the receipt verification logic allows for the support for both backend and client-side receipt validation, resulting in flexibility in the consumer’s implementation.
We turned on the feature flag initially for 50% of users, observing for related crashes and monitoring conversion rates across both buckets. We had discovered a crash caused by sending a nil reference from Objective-C to a non-optional parameter in Swift. Consider the following code snippet:
The Objective-C header for the method above specified the contract of the method with non-null parameters through NS_ASSUME_NONNULL. The consumer of this API was written in Swift and had specified the JSON parameter as a non-optional Any type. When fetching of the receipt failed, the code would call completion and send nil for the JSON field (which was consumed higher up the call chain for analytics purposes). Although the code successfully compiled, passing back nil from Objective-C to Swift failed to bridge due to a discrepancy in nullability quantifiers. Objective-C is lenient about the nullability discrepancy, whereas Swift isn’t, ultimately resulting in the crash.
The fix in this particular instance was to simply declare the nullability for the JSON parameter to be _Nullable. Although this fixes this one scenario, crashes of these types are tricky to detect and can reside in many other areas of the codebase. Luckily, Apple provides developers with a useful Xcode feature that allows you to find these tricky crashes using undefined behavior sanitizer. Among other things, UBSan helps to identify when a null value is incorrectly passed into an argument that expects a nullable value.
We enabled UBSan to prevent similar issues from happening in the future, and have caught similar issues across the codebase since. We strongly recommend that you enable this capability on your debug scheme to prevent potential crashes, especially if you are bridging between Objective-C and Swift.
Enterprise build support
We use the Enterprise developer program to distribute beta versions of the app internally within the company. Using the Enterprise program allows employees to test the latest version of the app without having to manually register their device to the developer portal. This strategy has worked great for us, except for the fact that the beta builds can’t make in-app purchases due to the Enterprise program’s limitations. Enterprise builds do not support the ability to purchase in-app purchases, and attempting to request SKProducts will result in an error.
This was a huge pain point for internal testing since our codebase relies on the presence of SKProducts to present paywalls. It was confusing to internal testers whether the lack of paywall presentation was due to the StoreKit limitation on Enterprise builds or a legitimate bug. To get around this discrepancy, we replaced direct references of StoreKit classes with wrapper objects that had identical fields.
A protocol was created around the fetching of products to abstract away the StoreKit dependencies…
This allowed us to bypass Apple’s limitations with Enterprise builds, allowing us to be able to present and test paywalls — and best of all the call site doesn’t change thanks to the use of protocols! Although we are still unable to make actual in-app purchases, it was a great step forward in keeping the user functionality as similar as possible across the two builds.
Our monolithic project could take upwards of 15 minutes to build, and introducing any new changes significantly slowed down development time. By using modules, we no longer had dependencies on the main app — we could build and test new changes much more quickly. Making changes to PurchaseService when running within the main app only causes that one specific framework to be built and no longer triggers a recompilation of the entire codebase.
Perhaps the most important reason to abstract the purchase layer of your app into a separate module is that it enforces the technique of dependency injection, resulting in the ability to test the purchase business logic independent of StoreKit’s APIs. However, dependency injection has many benefits even outside unit testing; we leveraged it to add paywall support in Enterprise builds by not relying directly on SKProducts. This can also be extended to support injecting JSON responses to develop client features where the backend for a new feature isn’t yet supported. Dependency injection provides flexibility in being able to manipulate the data so that components can be used to fit your specific use-case.
If your port-over involves migrating legacy Objective-C code to Swift, chances are this will be done across a series of steps. During the migration phase, the plumbing may be set up such that your Swift code may still be calling into Objective-C code. If this is the case, be cautious about nullability when calling Objective-C functions from Swift. Turning on Undefined Behavior Sanitizer across the project will help catch occurrences like this during testing in the future.
We had always envisioned faster development cycles, and leveraging modules is definitely a great step forward in that direction. The modularization effort has empowered teams in Tinder to be able to work more independently through a siloed development environment. At the time of this writing, we have expanded to 31 modules and are continually making an effort to port over additional components where applicable.
When working with a large team, breaking down a monolithic project into modules may seem like an overwhelming task. While there is an upfront cost in modularizing your app, the productivity gain will far exceed the initial investment. If you’re looking for a section of your codebase to modularize, consider starting with the purchase layer of your application.
How is the purchase layer for your iOS app implemented? Let us know in the comments your experience with this issue!