Migrating our Objective-C SDK to Swift
Let’s talk about our decision to migrate
When we first started RevenueCat, Jacob wrote our first SDK. At that time, Swift was still undergoing significant changes, and Swift SDK distribution was still not feasible in most cases. Objective-C tooling and distribution were both stable and battle-tested, plus Jacob refused to learn Swift (and probably won’t ever have to — good job waiting that one out!).
Four years later, Swift is mature and stable, and there’s a well-trodden path for distribution. When StoreKit2 was announced, we decided it was time to make the switch. We migrated our four-year-old Objective-C + Swift framework to pure Swift while retaining Objective-C support for our users with only the absolutely required API changes. We knew going into the migration that it wouldn’t be a trivial process, but we learned and experienced a lot of unexpected things along the way. Let’s talk about our decision to migrate; the preparation, development, and testing; and finally, how we successfully launched it with minimal customer impact.
Our story, like so many indie development stories, begins with an announcement Apple made during WWDC. At WWDC 2021, Apple announced a new version of the StoreKit framework (StoreKit 2), which would only support Swift and iOS 15. Our project was already jumping through a few hoops to be distributed through CocoaPods, Swift Package Manager, and Carthage with our mixed-language support. If we wanted to support the new APIs while also supporting the original StoreKit, previous iOS versions, and Objective-C, we’d have to go through a significant effort to figure out how.
- Build a brand-new SDK that only supports StoreKit2, and continue to support the original SDK for people who want to stick with StoreKit1.
- Modularize our framework by breaking the pieces into separate modules and ship a more complicated dependency structure.
- Migrate completely to Swift and keep everything in one module (for now).
We chose the tradeoff of a large up-front time investment to migrate in order to gain major long-term benefits. It was important for us to think about what we wanted our future SDK to look like and how we could start the evolution while also thinking about our company’s growth and scaling challenges.
Why we decided to migrate
There were a few factors that went into our decision to migrate to Swift.
Having a codebase that uses the same language
Mixing Swift with Objective-C in a consumer app usually isn’t a big deal. But for framework development, it’s a different story. Because we support multiple distribution channels, each with their own set of constraints (for example, SPM is limited to one language per package), having a single language makes our configuration for each distribution channel much simpler and less prone to problems.
We also support Flutter, React Native, Cordova, and Unity. Our SDK needs to work with these frameworks and be easy to set up, test, debug, and release. These things tend to get harder the more languages and modules you use.
Having a single module
Before the migration, we had two modules: Purchases and PurchasesCoreSwift. We originally built two separate modules so we could distribute one pure Objective-C package and one pure Swift package (a limitation of SPM). When we built new features, we had to do work in both the Purchases (Objective-C) module and in the PurchasesCoreSwift module. To make matters even more complicated, we could only reference PurchasesCoreSwift from Purchases, but not the other way around. By migrating to a single module, we dramatically simplified feature development. Now, we no longer have to think about where we need to cross the Objective-C/Swift boundary, where to place new classes, how to ensure the new classes have access to their dependencies, and what language to use.
Preventing our multiple distribution channels from getting even more complicated
StoreKit2 (SK2) is Swift-only. The more complicated bits of our SDK (like our caching and networking implementation) were written in Objective-C. Our Objective-C module also served as the entry point for our SDK. In order to reuse our battle-tested code and move to SK2 without migrating to Swift, we would need to start with a new Swift layer that exposes the SK2 API but can also call into Objective-C,—which also has Swift dependencies— (a nice little Swift/Objective-C/Swift sandwich). Mix in SPM’s requirement for a single language per package and you’re left with 3 packages and 3 CocoaPods. This could work, but it makes testing, building, and distributing pipelines much more complicated.
Making hiring and onboarding easier
Simplifying our project structure and moving to one modern language means new teammates can get up to speed faster. There is less to learn, less fighting with builds, and a simpler build and release pipeline. It also helps with recruiting. More and more engineers prefer Swift over Objective-C. There are lots of resources for learning Swift, and Apple is pushing Swift as the best language for iOS engineers to learn. That means as we continue to hire we can tap into a larger pool of people who can help us grow — “You only know Swift? No problem!”
Also, we all love learning new stuff and staying on top of the evolving mobile platforms. It’s a big part of our engineering culture. And Swift unlocks new ways of programming and problem-solving. For our teammates who didn’t know Swift yet, it was an exciting opportunity.
Prepping and planning the migration
We couldn’t just jump in — we needed to prepare by making a plan.
Setting the requirements
For this project, we set three main requirements:
- Maintain backwards compatibility wherever possible.
- No changing our internal API, unless absolutely necessary.
- Make minimal code improvements.
You can read more about why we chose these requirements in our migration plan, but it mostly came down to a couple of things: we wanted to minimize the number of moving pieces to prevent new bugs from slipping in and maintain a consistent API so that StoreKit2 work could be done in parallel. While it isn’t fun to forgo the new syntax and language features Swift offers, we were rewarded with extra speed, fewer potential bugs, and clear guidelines for code review — if the code looked significantly different from the Objective-C equivalent, we could flag it and discuss. We opted to wait to modernize the codebase until after we finished the migration.
Now, we were ready to sketch out the plan.
Organizing the migration
We decided to use GitHub’s tools to organize the project. We felt it was important to be fully transparent about what we were doing and make our project tracking public so that anybody could contribute or follow along.
Creating atomic pieces of work
We could have broken up the work in any number of ways, but ultimately, we went with file-level tasks. We created a single GitHub issue for each file that needed to be migrated. We quickly realized that we also needed to examine the dependencies so that we could migrate things in the right order and avoid duplicating work.
Examining each file for its dependencies.
Our framework has grown organically over the last 4 years. We’ve been focused on shipping features, fixing bugs, and ensuring our users could accomplish what they needed to, and we haven’t done a major refactoring. So while we had retrofitted our SDK with many best practices, each class had a web of dependencies we needed to unravel in order to efficiently parallelize the migration. If we had just started migrating, it would have rapidly devolved into yak shaving, since one dependency depends on many more, ad-infinitum.
Since ObjC.m files require you to #import all their dependencies, we started by going file by file and creating an issue for each, including the dependencies as a checkbox list in the description.
Ensuring backwards compatibility (mostly)
There were a few things that we simply couldn’t maintain backwards compatibility for. Things like exporting Swift Error domains to Objective-C — we just had to accept that, since it was auto-generated from our Swift module. We also found a number of places where our nullability directives were not quite right. Some API had been part of NS_ASSUME_NONNULL_BEGIN regions, but actually could return `nil` (like this or this), or it was even possible to pass in `nil` for some method parameters.
Moving from Objective-C to Swift surfaced these bugs and forced us to think about and handle a few cases that we hadn’t anticipated! So while these changes meant our API had to change a little, the upside was huge: these changes will prevent entire classes of bugs from existing in the future.
Tests, lots of tests
Importantly, we already had 700+ unit tests written in Swift for the Objective-C “Purchases” and Swift “PurchasesCoreSwift” modules. The tests enabled us to have more confidence in our changes. Looking back through all the tests also provided us an opportunity to add more tests to under-tested areas.
The APITester target
Before starting, we created a new target in the SDK project called APITester. This target is a minimal command-line app in Objective-C. It depends on the Purchases.framework. For each public object in the Purchases.framework, we created a single file in the app. There, we used every public API. The app was never meant to run — it was only there to ensure that any files we migrated didn’t impact the public API. If the app could be built without any modifications, we knew the API hadn’t changed. We set this app up as a target in our continuous integration so any commits would need to pass that check. It also served as a before/after for us as we documented breaking changes for users.
After a few weeks, it became clear a Swift-based APITester project would be really beneficial, so we added it. Both APITesters ended up catching a lot of changes that were not intentional. We had originally planned to only use the APITesters for the migration, but we found them so useful that we decided to add them to our test targets so that they run locally when we run our tests. Now that we had all the tasks broken down and organized, we were ready to start the actual development (which we’ll discuss in our next post)!
We turned a large, seemingly intractable problem into something we could comfortably tackle. We started with clearly documented goals and requirements and established that we were only migrating, not updating or upgrading our code. Next, we devised a clear step-by-step plan and broke the project down into manageable chunks of work that we prioritized based on their dependencies. We also set up thorough automated testing and API tester integrations to ensure that our new code was functionally equivalent to the old.
Proposing the migration, getting buy-in, and organizing the project was a lot of work, and at first, it seemed like that would be the most difficult part… until we started the work. Stay tuned for the next post in this series to learn about all the cropped-up issues and how we navigated them. (Spoiler alert: Team friction, merge conflicts, and external contributions, oh my!). If these challenges sound fun to you, check out our careers page.