Fifty apps in: what actually makes iOS code last
I've shipped more than fifty apps to the App Store. The ones that were a pleasure to open again two years later had almost nothing in common with the ones I thought were clever at the time.
Shipping an iOS app is the easy part — or at least the well-documented part. The hard part, the part nobody puts in a portfolio, is the two years after launch: the OS updates that break things, the small features bolted on by someone who wasn't there for v1, the bug reported at 11pm that lives three layers deep in a system you half-remember. Most of an app's life is maintenance. After fifty of them, here's what I've found actually survives contact with that.
Boring architecture wins
Every couple of years iOS has an architecture of the moment — VIPER, heavy Redux, whatever the conference talks are about. I've shipped most of them. The lesson is consistent: the clever pattern ages badly, and plain MVVM done without ceremony ages fine.
It's not that the fancy ones are wrong. It's that the next person to touch the code — frequently future-me, who has forgotten everything — has to relearn the cleverness before they can fix a one-line bug. Boring architecture is legible. When someone opens the project cold and immediately knows where the view logic lives, where the network call is, and where the state changes, you've already won the maintenance game.
Draw boundaries the compiler enforces
Good intentions don't keep code separated; import rules do. The single most useful thing I do on any app that'll live a while is split it into Swift Package modules where the boundaries are real — a feature module that can't import another feature, a networking module that knows nothing about UI. If crossing a boundary requires an import you have to add on purpose, you'll feel the wrong dependency before you ship it, not a year later when it's load-bearing.
The same instinct, smaller: put the things that change for reasons outside your control behind a small protocol you own.
protocol RateStore {
func save(_ rates: [Rate]) throws
func load() throws -> [Rate]
}
The app talks to RateStore. It never touches Core Data, SwiftData, or a file directly. When SwiftData arrived and I swapped the implementation underneath, exactly one type changed and nothing else in the app noticed. A boundary you own is a place you're allowed to change your mind.
Depend on Apple; distrust everyone else
Every third-party dependency is a bet that someone you've never met will maintain their code for as long as you maintain yours. Over a two-year horizon that bet often loses: the library gets abandoned, or breaks on the new Xcode, or makes an ABI change the week before your release.
So I've gotten conservative. First-party frameworks first, always — Apple is the one dependency guaranteed to still be here. For the rest, I ask three questions before adding anything: Could I write the part I actually use in a day? Who maintains this, and will they next year? What happens to my release if it breaks the morning of? If the honest answers are "yes," "unclear," and "I'm stuck," I vendor the twenty lines I need and move on.
Every line is a liability someone has to read, build, and not break. I delete aggressively — dead feature flags, the abstraction that only ever had one implementation, the "might need it later" helper. A smaller codebase isn't a stylistic preference; it's less surface area for the next bug to hide in.
Test what's expensive to get wrong
I don't chase coverage numbers. High coverage on an app that's mostly glue tells you the glue is glued. What I test, without fail, is the code where a mistake actually costs something: the purchase and subscription paths, the data migrations that can corrupt a user's history, and the pure algorithms — the GPS filter, the currency math, the parsing.
Those tests have a property the UI tests never had: they're fast, they're stable, and they keep paying out for years without flaking. A unit test on a migration has saved me from shipping a data-loss bug more than once. A snapshot test on a screen has mostly just broken whenever a designer nudged a margin.
Write for the reader
The compiler will accept almost anything. The person reading this code in eighteen months — possibly you, definitely tired — will not. So I optimize for them: honest names, short functions, and a hard veto on cleverness that saves three lines at the cost of a re-read.
This is why the Kalman filter in the GPS post is about thirty plain lines. It could be denser. It shouldn't be — because the real requirement isn't "works," it's "still makes sense to someone who has to change it later." Code is read far more than it's written, and on a long-lived app it's read by people running on no context at all.
Budget for the churn
Every WWDC breaks something, and pretending otherwise just means the breakage finds you in September instead of June. I keep deployment targets moving rather than stranding an app on an old OS, I adopt new APIs deliberately instead of all at once, and I treat the annual "fix what Xcode broke" pass as planned work, not a surprise.
None of this is exciting, and that's the point. The real test of iOS code isn't whether it shipped — almost anything ships. It's whether, a year from now, you can open the project and make a change in an afternoon without being afraid. Fifty apps in, that's the only metric I really trust.