Six months after your AI-assisted Objective-C to Swift migration, the app is stable. The crashes are fixed. Production is humming along. But your development velocity has slowed to a crawl. Every new feature takes longer to implement than it should. Your team complains that the code "feels wrong." New Swift developers joining the team struggle to understand patterns that don't match anything they've seen before.
What happened?
The migration technically succeeded. The Objective-C code became Swift code. Everything compiles and runs. But the AI tool missed something crucial: the opportunity to make your codebase actually modern. Instead of Swift that embraces the language's idioms and capabilities, you have Objective-C thinking translated into Swift syntax.
This is the hidden cost of AI-only migrations. They preserve not just your code but also your technical debt, your outdated patterns, and your architectural decisions from a decade ago. The result is a codebase that's harder to maintain than it needs to be, slower than it should be, and more resistant to change than modern Swift allows.
Let me show you the five categories of missed opportunities that create compounding technical debt in AI-migrated codebases.
When Everything Becomes a Class
Swift introduced something revolutionary: value types that are first-class citizens of the language. Structs aren't just lightweight data containers anymore. They can have methods, conform to protocols, and be extended. They provide automatic thread safety through copy-on-write semantics. They make your code more predictable and your bugs less subtle.
But AI tools convert every Objective-C @interface to a Swift class, regardless of whether that's the right choice.
Consider this common model object from an Objective-C codebase:
@interface UserPreferences : NSObject
@property (nonatomic, strong) NSString *theme;
@property (nonatomic, assign) BOOL notificationsEnabled;
@property (nonatomic, assign) NSInteger fontSize;
@end
An AI tool converts this to:
class UserPreferences: NSObject {
var theme: String
var notificationsEnabled: Bool
var fontSize: Int
init(theme: String, notificationsEnabled: Bool, fontSize: Int) {
self.theme = theme
self.notificationsEnabled = notificationsEnabled
self.fontSize = fontSize
}
}
This works perfectly. It compiles, it runs, and it behaves identically to the Objective-C version. But it's the wrong type.
UserPreferences is pure data. It has no identity, no lifecycle, no side effects. It's exactly the kind of thing that should be a struct in modern Swift:
struct UserPreferences {
var theme: String
var notificationsEnabled: Bool
var fontSize: Int
}
The struct version is simpler, faster, and safer. No inheritance from NSObject that you don't need. No reference counting overhead. Automatic thread safety because structs are copied rather than shared. Automatic Equatable conformance if you need it. Cleaner semantics throughout your codebase.
But here's what makes this a compounding problem: this decision affects every piece of code that touches UserPreferences. If it's a class, you need to think about whether two variables point to the same instance. You need to be careful about mutating it because changes are visible everywhere that shares the reference. You need to consider thread safety manually.
With a struct, none of that matters. Mutations are local. Passing it around doesn't create shared mutable state. The type system helps you reason about your code.
In many migrated codebases, 90% of model objects should have been structs but were converted to classes. The result is reference semantics where value semantics would be clearer and manual synchronization where the type system would provide safety.
The AI tool made the safest conversion choice: preserve the Objective-C semantics exactly. But "safe" in terms of behavior preservation isn't the same as "correct" in terms of Swift design. Every missed opportunity to use value types is technical debt that makes your codebase harder to reason about and more prone to threading bugs.
The Protocol Conformance Compromise
Protocols in Objective-C and Swift serve similar purposes but have fundamentally different capabilities. Objective-C protocols support optional methods through the @optional keyword. Swift protocols don't, because Swift has better ways to handle optional requirements.
When AI tools encounter Objective-C protocols with optional methods, they face a choice: convert to Swift protocols with @objc optional requirements, or redesign the protocol hierarchy. Invariably, they choose the first option because it's mechanically straightforward.
Here's a typical Objective-C delegate pattern:
@protocol DataSourceDelegate <NSObject>
- (void)dataSourceDidUpdate:(DataSource *)dataSource;
@optional
- (void)dataSource:(DataSource *)dataSource didFailWithError:(NSError *)error;
- (BOOL)dataSourceShouldRefresh:(DataSource *)dataSource;
@end
The AI agent converts this to:
@objc protocol DataSourceDelegate: NSObjectProtocol {
func dataSourceDidUpdate(_ dataSource: DataSource)
@objc optional func dataSource(_ dataSource: DataSource, didFailWithError error: Error)
@objc optional func dataSourceShouldRefresh(_ dataSource: DataSource) -> Bool
}
This preserves exact Objective-C behavior. Call sites can check if the delegate responds to optional methods and call them conditionally. It works identically to the original.
But it's also deeply un-Swift. The @objc optional requirement means this protocol can only be adopted by classes, not structs or enums. It requires the Objective-C runtime for method dispatch. It prevents compile-time checking of whether optional methods are implemented. It's Objective-C semantics wearing Swift syntax.
Modern Swift handles optional protocol requirements through protocol extensions with default implementations:
protocol DataSourceDelegate: AnyObject {
func dataSourceDidUpdate(_ dataSource: DataSource)
func dataSource(_ dataSource: DataSource, didFailWithError error: Error)
func dataSourceShouldRefresh(_ dataSource: DataSource) -> Bool
}
extension DataSourceDelegate {
func dataSource(_ dataSource: DataSource, didFailWithError error: Error) {
// Default implementation: do nothing
}
func dataSourceShouldRefresh(_ dataSource: DataSource) -> Bool {
true
}
}
Now every conforming type gets default behavior for the "optional" methods, but can override them if needed. No runtime checking, no @objc attribute, no dynamic dispatch overhead. The compiler knows at compile time exactly which methods exist.
Or better yet, split the protocol:
protocol DataSourceDelegate: AnyObject {
func dataSourceDidUpdate(_ dataSource: DataSource)
}
protocol DataSourceErrorHandling: DataSourceDelegate {
func dataSource(_ dataSource: DataSource, didFailWithError error: Error)
}
protocol DataSourceRefreshControl: DataSourceDelegate {
func dataSourceShouldRefresh(_ dataSource: DataSource) -> Bool
}
Now types can adopt only the protocols they need. The type system enforces which capabilities each delegate has. No runtime checks, no optional method handling, just clean protocol composition.
AI tools don't make these architectural decisions because they require understanding the intent behind the protocol design. They see optional methods and preserve them mechanically, creating protocols that work but don't leverage Swift's capabilities.
The result is a codebase full of @objc protocols that restrict what types can conform, prevent value type usage, and carry the overhead of dynamic dispatch. Every protocol interaction requires runtime checks that the compiler could be enforcing at build time. It's technical debt that compounds every time you add a new protocol or conform to an existing one.
Method Signatures That Fight the Language
Swift has strong opinions about API design. Apple's API Design Guidelines encourage clarity at the point of use, self-documenting parameter names, and consistent patterns across the standard library. Objective-C had different conventions: verbose method names, positional parameter meanings, and patterns that made sense in its ecosystem.
When AI tools convert Objective-C method signatures to Swift, they translate the words but miss the idioms. The result is APIs that feel foreign in Swift codebases.
Consider this common Objective-C pattern:
- (void)setUser:(User *)user animated:(BOOL)animated completion:(void (^)(void))completion;
An AI tool converts this to:
func setUser(_ user: User, animated: Bool, completion: @escaping () -> Void)
This compiles and works. But it violates Swift naming conventions. The first parameter lacks a label, making the call site less clear. The method name includes "set" when Swift convention prefers property-style naming for setters. The boolean parameter is ambiguous at the call site.
Compare that converted signature to an idiomatic Swift version:
func update(to user: User, animated: Bool = true, completion: @escaping () -> Void = {})
The call sites tell the story:
// AI-converted version:
setUser(newUser, animated: true, completion: {
print("Done")
})
// Idiomatic Swift version:
update(to: newUser) {
print("Done")
}
The Swift version reads naturally. "Update to new user" makes sense in English. Default parameters eliminate boilerplate for common cases. Parameter labels create self-documenting code.
But here's where this becomes a compounding problem: method signature design affects every call site in your codebase. When you have hundreds or thousands of methods with awkward signatures, your entire codebase fights against Swift conventions. New developers joining the team struggle because nothing matches what they've learned from Swift's standard library and modern frameworks.
Migrated codebases often have method names that include prefixes like get, set, and is unnecessarily, where boolean parameters lack descriptive labels, and where parameter order doesn't follow Swift conventions. Every method call requires mental translation from Swift idioms to Objective-C patterns, creating cognitive overhead that slows development.
The AI tool preserved the original method behavior perfectly, but method signatures are part of your API's usability. When that usability fights the language's conventions, every interaction becomes friction.
The Bridging Overhead Hidden in Plain Sight
Swift and Foundation types bridge between each other automatically in many cases. String and NSString, Array and NSArray, Dictionary and NSDictionary. This bridging usually "just works," which is convenient but also dangerous because it hides performance implications.
AI-migrated code tends to bridge between Swift and Foundation types constantly because the original Objective-C code used Foundation types everywhere. The mechanical conversion preserves those types, and Swift's automatic bridging makes everything compile. But every bridge operation has a cost.
Consider this pattern that appears frequently in migrated networking code:
- (NSArray *)parseJSON:(NSData *)data {
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
NSArray *items = json[@"items"];
return items;
}
The AI converts this to:
func parseJSON(_ data: Data) -> [Any] {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let items = json["items"] as? [Any] else {
return []
}
return items
}
Every call to this method now involves bridging: Data (Swift) to NSData (Foundation), NSDictionary back to [String: Any], NSArray back to [Any]. These bridges aren't free. They copy data, perform type checks, and sometimes allocate new memory. In a tight loop or on performance-critical paths, they add up.
Modern Swift has better alternatives. For JSON parsing, Codable eliminates bridging entirely:
struct Response: Codable {
let items: [Item]
}
func parseJSON(_ data: Data) -> [Item] {
(try? JSONDecoder().decode(Response.self, from: data))?.items ?? []
}
No bridging, no type casting, no runtime type checks. The JSON response is decoded directly to Swift types, and the compiler enforces correctness.
But AI tools don't make these architectural changes. They preserve Foundation API usage because that's what the original code did. The result is a codebase that bridges constantly between Swift and Foundation types, paying performance costs that modern Swift APIs would eliminate.
Profiling migrated apps can reveal that 15-20% of CPU time in hot paths is spent in bridging operations. Not business logic, not complex algorithms, just converting between equivalent types because the migration preserved Objective-C API usage patterns. Optimizing that away requires identifying every place Foundation types could be replaced with Swift equivalents, understanding the performance implications, and systematically modernizing.
The AI converted your code successfully. But it didn't make your code fast.
Legacy APIs Preserved for Eternity
Perhaps the most frustrating category of technical debt in AI migrations is the preservation of deprecated and legacy APIs. These are APIs that still work, that compile without warnings, but that have been replaced by better alternatives in modern Swift.
Consider manual JSON parsing with JSONSerialization, which we just discussed. It works fine. But Codable exists now, providing type safety, compiler enforcement, and cleaner code. The AI tool converts JSONSerialization usage because that's what the original code did, missing the opportunity to modernize.
Or threading code using DispatchQueue directly when async/await would be clearer and safer:
// AI-converted code:
DispatchQueue.global().async {
let result = self.performExpensiveOperation()
DispatchQueue.main.async {
self.updateUI(with: result)
}
}
// Modern Swift:
Task {
let result = await performExpensiveOperation()
updateUI(with: result)
}
The async/await version is simpler, harder to get wrong, and compiler-enforced. But the AI preserved the dispatch queue pattern because that's what was there.
Or manual string formatting when string interpolation is more readable:
// AI-converted code:
let message = String(format: "User %@ has %ld items", user.name, itemCount)
// Modern Swift:
let message = "User \(user.name) has \(itemCount) items"
Each of these individually seems minor. But across a codebase with thousands of function calls, the accumulated technical debt is substantial. Your code works, but it's using patterns from 2015 when better alternatives exist today.
What makes this particularly insidious is that these patterns continue to propagate. New code written by developers who are following existing patterns will use the old APIs because that's what they see in the codebase. The technical debt doesn't just sit there, it grows over time as developers cargo-cult the patterns they find.
Codebases five years after migration can still have new code using manual JSON parsing, dispatch queues instead of async/await, and string formatting instead of interpolation. Not because the developers didn't know about modern alternatives, but because they were matching the patterns already present. The AI migration locked in 2015-era Swift patterns that remained frozen in time.
The Compounding Cost of Technical Debt
If you're adding up the impact of these five categories, you might be thinking: "Sure, but the code works. We can clean this up later."
That's technically true. But there's a hidden cost to "later" that compounds over time.
Every class that should be a struct creates subtle threading bugs and forces careful reasoning about shared mutable state. Every @objc protocol restricts what types can conform and prevents value type usage. Every awkwardly named method slows down development as developers mentally translate between conventions. Every bridging operation takes CPU cycles in hot paths. Every legacy API pattern gets copied into new code.
Six months after migration, your velocity has slowed because every feature requires working around these patterns. A year after migration, new developers are confused because the codebase doesn't match modern, idiomatic Swift. Two years after migration, you're still avoiding structs and protocols because the foundation was built on classes and @objc.
The AI migration saved you 6-12 months of conversion time. But if it created technical debt that slows your development velocity by 20% forever, was it worth it?
This is why I advocate for the hybrid approach: use AI for the mechanical conversion, but pair it with expert review that identifies these modernization opportunities. Convert the syntax automatically, but make conscious architectural decisions about which patterns to preserve and which to modernize.
The goal isn't perfect code from day one. It's code that's positioned to improve over time rather than calcify. Code that new developers can learn from rather than be confused by. Code that leverages Swift's strengths rather than working around them.
What Modern Swift Migration Looks Like
When I review AI-migrated codebases with clients, we categorize the technical debt into three tiers:
Critical: Changes that affect correctness, safety, or performance significantly. These need to be fixed before shipping. Class-to-struct conversions for model objects shared across threads. Protocol redesigns that enable compile-time checking. Method signatures that create dangerous ambiguity.
Important: Changes that significantly improve maintainability and development velocity. These should be addressed during migration or shortly after. Protocol conformance modernization. API naming cleanup. Bridging overhead elimination in hot paths.
Nice-to-have: Changes that improve code quality but don't affect functionality. These can be addressed incrementally over time. Legacy API replacement. Consistency improvements. Documentation updates.
The hybrid approach handles critical changes during migration, identifies important changes as follow-up work, and documents nice-to-have improvements for ongoing maintenance. The result is code that works correctly and is positioned to improve rather than calcify.
Because the goal isn't just "migrated code." It's "modern Swift code that your team can maintain and improve for years to come."
Next Steps
If your codebase has been through an AI migration and feels harder to work with than it should, or if you're considering migration and want to do it right, let's talk. I offer a free 30-minute consultation where we can discuss your specific situation and what modern Swift migration means for your codebase.
For projects that move forward, I provide a detailed paid assessment that identifies these exact technical debt patterns in your codebase, along with a modernization plan that addresses them systematically. You'll get a clear roadmap showing which changes are critical, which are important, and which can be deferred.
With 15 years of Apple development experience and as the founder of Cocoacasts, I've analyzed dozens of small and large codebases. I know what modern Swift looks like, and I know how to get there from Objective-C.
Series Conclusion
Over these three posts, we've explored the three categories of problems that plague AI-only migrations:
Memory management mistakes that cause crashes and leaks in production. These are the most visible failures, the ones that wake you up at 3 AM with crash reports.
Runtime behavior mismatches that cause subtle bugs with specific data or scenarios. These are the ones that make it past testing and surprise you in production.
Technical debt accumulation that slows development and prevents your codebase from leveraging modern Swift. These are the ones that hurt you for years after migration.
AI tools are excellent at syntax conversion. They can transform 70% of your codebase quickly and accurately. But that remaining 30%, the semantic understanding, the architectural decisions, the modernization opportunities, requires human expertise.
The hybrid approach combines the speed and cost-effectiveness of AI with the judgment and experience of an expert reviewer. The result is code that doesn't just compile and run, but code that your team can maintain, improve, and build on for years to come.
That's what successful migration looks like.
Thanks for reading this series. If you found it valuable, please share it with others facing similar migration decisions. And if you're ready to migrate your own Objective-C codebase, I'd love to help.