What years of shipping iOS apps taught me about writing good software
Lessons from production iOS work on state management, premature abstraction, and knowing when good enough is good enough.
I started writing Swift in 2014. Later I also spent time in Objective-C, along with the usual phase of being a computer science student who thought design patterns were something you implemented for the sake of implementing them.
Years of production work, several shipped apps, and a lot of late-night debugging sessions later, here’s what actually stuck.
Lesson 1: The Best Code Is Code You Didn’t Write
Early in my career, I was obsessed with abstraction. Every network call went through a generic NetworkManager<T: Decodable>. Every UI component had a protocol-based factory. I once wrote a generic DataSource wrapper that could power both UITableView and UICollectionView with the same underlying data model.
It was beautiful. It was also completely unnecessary for the app I was building, which had three screens and two API endpoints.
The problem with premature abstraction isn’t that the abstractions are bad — it’s that they’re wrong. You don’t know the right abstraction until you’ve seen three concrete examples of the pattern. When you abstract too early, you’re guessing. And when the requirements change (they always change), your generic solution is harder to modify than three simple implementations would have been.
My rule now: write the code concretely. When you see the same pattern three times, then extract it. Not because three is a magic number, but because by the third time, you actually understand the invariants.
Lesson 2: State Bugs Are Architecture Bugs
Many of the “impossible” bugs I’ve debugged in recent years have turned out to be state management problems. The button that’s disabled even though the form is valid. The loading spinner that never stops. The profile screen that shows stale data after editing.
These bugs aren’t caused by typos or logic errors. They’re caused by state being mutated from multiple places without a clear owner. When your UserProfile can be updated by the settings screen, the profile editor, a push notification handler, and a background sync task, you’re going to have inconsistent state. It’s not a matter of if, it’s when.
This is what pushed me toward architectures that enforce clearer state ownership — first MVVM with careful state management, and eventually TCA. When there’s a well-defined path for state changes, you can reason about your app. When state can change from five different directions, you’re just hoping.
Lesson 3: Tests You Don’t Run Are Worse Than No Tests
I worked on a codebase that had 3,000 unit tests with 85% code coverage. Impressive on paper. In practice, the test suite took 14 minutes to run, so nobody ran it locally. CI ran it, but by the time the results came back, the developer had context-switched to something else.
The result? Tests broke silently. Old tests were deleted when they became inconvenient. New features shipped without tests because “we’ll add them later” (we didn’t).
I now optimize for test speed before chasing test coverage. A test suite that runs in 30 seconds and covers the critical paths is worth more than a comprehensive suite that takes 15 minutes. If the tests are fast, developers run them before every commit. If they run before every commit, broken tests get fixed immediately. If broken tests get fixed immediately, the test suite stays trustworthy.
TCA’s TestStore is usually fast because it’s exercising reducer logic in-process — no simulators, no UI rendering, no network waits. A suite of 200 TCA tests runs in under 5 seconds on my M1 MacBook. I run them reflexively, like saving a file.
Lesson 4: Code Review Is About Communication, Not Gatekeeping
The best code reviews I’ve received weren’t the ones that caught bugs — though those are valuable. They were the ones that taught me why something should be done differently.
“This works, but did you consider TaskGroup instead of async let? When the number of concurrent requests is dynamic, TaskGroup is often a better fit, and its structure makes it easier to reason about completion and cancellation.”
That’s not a gate. That’s a more experienced engineer handing something down.
I try to write reviews the same way. Not every comment needs an explanation — sometimes “LGTM” is genuinely appropriate. But for non-obvious decisions, the reasoning matters more than the correction itself. The correction fixes today’s problem. The reasoning prevents tomorrow’s.
Lesson 5: Your App Architecture Should Match Your Team, Not Your Aspirations
I’ve come back to this lesson in different forms over the years, but it bears repeating.
I’ve seen startups with two iOS developers lose months to VIPER boilerplate. I’ve seen agencies deliver excellent apps with basic MVC because the team was experienced and disciplined. I watched a team adopt TCA, struggle for three weeks, and then ship the most maintainable codebase I’ve ever worked on.
The common thread? The architecture matched the team’s experience and the project’s actual requirements. Not the imagined future requirements. Not what the team wished they were building. What they were actually building, with the people actually available.
What I’m Focused on Now
My current technical interests, which you’ll see reflected in future posts here:
- The Composable Architecture: I’ve now used it across multiple production apps and I’m still discovering better ways to compose and test features. The testing story alone is worth the investment.
- SwiftUI performance: Not just “make it work with SwiftUI” but “make it perform at 120fps on ProMotion displays.” There’s a lot of subtlety in how SwiftUI diffing interacts with complex view hierarchies.
- Cross-platform patterns: How ideas from one platform (iOS, web, Android) inform better solutions on another. My iOS background directly influenced how I built this blog’s architecture.
- Developer tooling: Improving the daily experience of writing, testing, and debugging code. The compound effect of small tooling improvements over months is enormous.
If any of that sounds interesting, stick around. Everything I write here comes from production experience, not theoretical exercises.