I joined the company in 2019 to build a bank. Then COVID happened.

The banking project got shelved. People got reassigned. I ended up on a food delivery app that was absolutely exploding in growth. Turns out lockdowns are great for food delivery, terrible for experimental banking products.

My new team mentioned, almost as an aside, that they’d been “migrating the iOS app to Swift” for a couple years. Great! I love Swift. I could help with that.

I opened the codebase. 1.3 million lines of code. 750,000 lines still in Objective-C. Custom libraries that didn’t map to modern Swift patterns. A modularization strategy that somehow resulted in most code living in a handful of Core modules. And a migration that had been “in progress” since 2017.

The policy since 2018 was “all new code in Swift.” Sounds good in theory. In practice? Most of the core features (authentication, navigation, data management) were still in Objective-C. So engineers were either writing bridging code constantly or just giving up and writing new features in Objective-C too because it was easier than fighting the architecture.

This was going to be fun.

Detective mode activated

What I Signed Up For vs. What I Got

I thought I was joining a team that needed help rewriting code. What I actually walked into was a migration that had lost all momentum. There was no clear strategy beyond “rewrite things when you touch them.” The 1.3 million line codebase had tangled dependencies everywhere. Custom libraries and Objective-C patterns didn’t translate to Swift. The modularization strategy existed in theory but not in practice. And every engineering team needed to ship features, not pause for migrations.

Before I could write any Swift, I needed to understand what actually existed, why the migration had stalled, and how to create a path forward that wouldn’t take 8 more years. That archaeology took nearly a year.

The Origin Story

The app wasn’t born from a hackathon or a prototype that escaped. It was built by a small team testing a product idea. The product worked. Users loved it. It grew.

Fast forward a few years: the app had become successful, the codebase had grown to over a million lines of code with 750k still in Objective-C, and the company’s platform standards had evolved. The app? Still using patterns from 2014.

Someone decided: “We should migrate to Swift.”

They kicked off the effort. Some screens got rewritten. Then… it stalled. Teams had features to ship. The migration was important but never urgent. People moved on to other projects. By 2019, it was the thing everyone knew needed to happen but nobody had time for.

The Unique Challenges

Objective-C Patterns That Don’t Translate

The codebase was full of patterns that worked great in Objective-C but had no clean Swift equivalent. Custom libraries built for dynamic dispatch. Unsafe patterns that Swift actively prevents. Code that relied on Objective-C runtime magic.

Want to migrate a feature? First, figure out how to translate patterns that Swift wasn’t designed to support. Some examples:

  • Custom libraries built assuming Objective-C semantics (not RxSwift or Combine compatible)
  • Dynamic method resolution and runtime manipulation
  • Memory management patterns that don’t map to Swift’s ARC
  • Categories with associated objects (Swift extensions can’t add stored properties)
  • KVO on private properties

You couldn’t just rewrite Objective-C to Swift line by line. You needed to rearchitect entire subsystems to use Swift-idiomatic patterns. And do it incrementally while keeping everything working.

Some engineers had solved parts of these problems. But the solutions weren’t widely circulated. And even when they were shared, most engineers looked at the approach and thought “that’s risky, I could break production.” Better to just keep writing Objective-C than risk a P0 incident or spend your weekend fixing a hotfix.

So the migration stalled.

The Modularization That Wasn’t

The app was designed with modularization in mind. In theory, features lived in separate modules with clear boundaries. In practice?

Most code lived in Core modules: CoreLibraries, CoreFeatures, CoreIntegration. Features depended directly on Core. Core depended on features. Dependency arrows pointed in every direction. The module graph was a tangled mess.

Want to migrate one feature to Swift? Too bad, it depends on dozens of things in Core. Want to migrate Core? It depends on every feature.

The circular dependencies meant you couldn’t migrate anything in isolation. But migrating everything at once was impossible.

This was why the migration had stalled. People would try to migrate a feature, hit the dependency wall, and give up.

Nobody Remembered the “Why”

The original architects had moved on. Some left the company. Some switched teams and lost context. The current team maintained the app but didn’t necessarily understand the deeper architectural decisions.

I’d ask: “Why does this feature reach directly into Core instead of using the API layer?”

The answer: “That’s just how it’s always been.”

Or: “Why do we have this custom navigation system instead of using UIKit’s built-in coordinator pattern?”

The answer: “I think someone tried to fix a bug in 2015? Not sure.”

The knowledge wasn’t lost because people left. It was lost because the people who knew had moved on to other problems years ago.

This is fine

The Archaeology Phase

I spent months just understanding what existed and why the migration had stalled.

1. Map the Dependency Graph

I needed to understand what depended on what. The module structure was complex: Core app module, CoreLibraries, CoreFeatures, CoreIntegration. Code that shouldn’t be together lived in the same modules.

I used Buck’s query system to analyze dependencies. Built scripts to generate Gephi visualizations showing each module’s children and descendants. The graphs were horrifying. Everything was connected to everything.

But they were useful. I could identify hot spots: code that was heavily imported, or code that heavily relied on other things. These hot spots were the bottlenecks. Fix them, and you could unblock entire features for migration.

2. Interview the Veterans

I found everyone who had worked on the codebase. Made a list. Scheduled coffee chats.

“Why did we build these custom libraries?” “What was the original modularization plan?” “Do you remember why this code uses runtime tricks instead of protocols?”

Most people didn’t remember specifics. But they remembered problems. “We needed something fast.” “We wanted feature teams to own their modules.” “We tried to avoid massive view controllers.”

The problems were useful. They explained the “why” even when the “what” was forgotten.

3. Find the Stall Points

I looked at every Swift file that existed. What had been migrated? Where did it live? What patterns did it use?

Pattern: Nobody was actually rewriting old Objective-C code. Instead, engineers were writing new features in Swift and using interop layers the mobile platform team had created to bridge between the two languages.

It worked… sort of. New code was Swift. But the old Objective-C code stayed Objective-C. And the core features that everything depended on? Still Objective-C.

The migration had stalled because nobody had solved the hard architectural problems. The interop layers let people avoid the problem, not solve it.

4. Understand the Boundaries (Or Lack Thereof)

I traced through code paths. “When a user taps this button, what happens?”

The answer was usually: “Code in Feature A calls Core, which triggers something in Feature B, which calls back to Core.”

There were no clean boundaries. No dependency inversion. No protocols defining interfaces. Just direct dependencies everywhere.

To make progress, we’d need to create boundaries that didn’t exist.

Digging deeper

The Realization

After all this archaeology, the picture was clear.

The migration hadn’t stalled because people were lazy or because Swift was hard. It stalled because the architecture made incremental migration impossible.

All the modules were tangled together, so you couldn’t migrate one at a time. Many patterns had no Swift equivalents, so you needed to rearchitect entire subsystems. And without understanding why the code was structured the way it was, you couldn’t move fast.

The real work wasn’t rewriting Objective-C to Swift. It was understanding the existing architecture, creating boundaries where none existed, introducing dependency inversion, building bridges between Objective-C patterns and Swift idioms, proposing API changes that allowed incremental migration, and convincing teams this was worth doing.

Only then could you start migrating code.

The Path Forward

After all this archaeology, I had a plan.

First, fix the dependency graph. Introduce protocols and dependency inversion. Break circular dependencies between Core and features. Create clear module boundaries.

Then, build bridging layers. Create adapters between Objective-C patterns and Swift idioms. Allow Objective-C and Swift code to coexist without forcing teams to rewrite everything at once.

From there, propose API changes. Make features define their interfaces as protocols. Let Core depend on abstractions, not concrete implementations. Create a pattern that teams could follow incrementally.

All of this would enable incremental migration. Teams could migrate their feature in isolation, on their own schedule, while the app stays stable the entire time.

This wasn’t sexy work. It was infrastructure. Architecture. Boundary design. But it was the work that would actually unblock dozens of engineers to make progress.

What I Learned

Migrations don’t stall because of technical problems. They stall because of architectural ones. If incremental progress is impossible, people will give up.

I could have joined the handful of people rewriting screens. Instead, I spent months understanding why that approach wasn’t working. Understanding why something stalled matters more than pushing harder.

If I could go back, I’d tell myself to budget the archaeology time upfront. Talk to veterans. Map dependencies. Find the stall points. Before you propose a solution, understand the problem.

Sometimes the most valuable work isn’t writing code or shipping features. It’s designing the interfaces that will allow future work to happen. And writing everything down as you go, because that documentation becomes the playbook for the migration.

I loved the archaeology phase, honestly. There’s something satisfying about being the person who finally identifies the architectural problems that everyone felt but nobody had named. You become the person who can explain why this is hard, what needs to change, and how to make incremental progress possible.

That’s platform engineering. Creating the conditions that let everyone else write code successfully.

Victory

What’s Next

So here I am, months into a “revive the Swift migration” project. I’ve mapped the dependencies. I’ve interviewed the veterans. I’ve identified why the migration stalled.

I’ve proposed architectural changes: dependency inversion, protocol-based boundaries, bridging layers between Objective-C patterns and modern Swift idioms.

Now comes the easy part: convincing dozens of engineers across many teams that these architectural changes are worth doing. And that we should pause feature work to fix the foundations.1


Footnotes

  1. Narrator: This was not the easy part. Stay tuned for Part 2. ↩