Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 

Repository files navigation

iOS Architecture Review Checklist

A 40-point checklist for auditing an iOS codebase's module boundaries, dependency health, build performance, runtime performance, testing seams, release engineering, security posture, and team scalability. Run it against your codebase in an afternoon: go section by section, mark each item pass/fail/unknown, and you'll walk away with a prioritized list of what's actually slowing your team down or putting users at risk — not a generic best-practices lecture, but a working audit tool built from what actually breaks at scale.

Who wrote this

I help teams ship iOS apps that scale — architecture, build performance, and mobile security for codebases with real users. I've spent 20+ years (since 2007) building the iOS infrastructure other companies ship on, including an SDK at ~70% market penetration, an identity-verification SDK acquired by Socure, and the original Instacart prototype back in YCombinator 2012.

How to score

There's no single "pass" number — this isn't a certification. For each of the 8 sections, count how many of the 5 items are solid (pass), shaky (needs work), or unknown (nobody's checked). A section with 3+ unknowns means nobody has looked closely at that area recently — that's your starting point, not the sections with the most fails. Treat the counts as a conversation starter for the team, not a precise score to optimize.

Want help?

If the audit surfaces more than you want to tackle alone — or you'd rather have a second set of eyes run it with you — I offer architecture audits: a structured review of your codebase against this checklist, plus a prioritized remediation plan. Reach me at greensamuelm@gmail.com.


iOS Architecture Review Checklist

40 items across 8 sections. Run this against a real codebase in an afternoon — not a whiteboard exercise.

1. Module boundaries

1. Audit dependency direction between modules

Map which modules import which, and confirm the graph points one way — features depend on core, core never depends on features. Use xcodebuild -showBuildSettings or a tool like swift-dependency-graph/periphery to generate the actual import graph rather than trusting the diagram in the wiki. Bad looks like a "core" module that imports a feature module for one helper function, which silently locks every feature into the same build unit.

2. Check feature module isolation

Each feature module should build and run in isolation, ideally with its own SwiftUI preview or demo target, without pulling in unrelated features. Verify by deleting a feature module's Package.swift target and seeing if anything outside it fails to compile. Bad is a "delete this feature" that turns into a week of untangling because three other features reached into its internals.

3. Find the "Common"/"Utils" dumping ground

Search for a module named Common, Shared, Utils, or Core and check its file count and import fan-in with git log --stat or a dependency-graph tool — if half the app imports it, it's not a module, it's a landfill. This matters at scale because every unrelated change to that module triggers a full rebuild of every consumer. Bad is a 400-file "Utils" target that mixes networking, date formatting, and analytics.

4. Review public API surface per module

List each module's public/open declarations (Xcode's "Interface Builder" or swift-api-digester can generate this) and check whether internals leak out as public by default. A minimal, intentional public surface is what makes a module safely replaceable; a module where 90% of types are public means every internal refactor is a breaking change for consumers. Bad is a module where internal is never used because nobody thought about the boundary.

5. Detect circular dependencies

Run a dependency-graph tool (periphery, swift package show-dependencies, or Xcode's own build graph in verbose build logs) and look for cycles between targets. Circular dependencies force Swift Package Manager or CocoaPods into slower, less parallelizable builds and make it impossible to extract or reuse a module later. Bad is ModuleA and ModuleB importing each other through a "just one protocol" back-channel that nobody remembers adding.

2. Dependency graph health

6. Count and audit third-party dependencies

List every entry in Package.resolved or Podfile.lock and check last-updated date, maintainer activity, and binary size contribution via xcodebuild -showBuildSettings or App Thinning Size Report. At scale, each unaudited dependency is a supply-chain risk and a build-time tax. Bad is 60+ pods where nobody can say why half of them are there.

7. Check for wrapper layers around SDKs

Verify that third-party SDKs (analytics, networking, ads) are accessed through an internal protocol/wrapper rather than called directly from feature code — grep for the SDK's import statement outside a single adapter file. This is what lets you swap or upgrade a vendor SDK without a codebase-wide find-and-replace. Bad is Firebase.Analytics.log(...) scattered across 200 call sites.

8. Verify dependency injection seams exist

Check whether core services (networking, persistence, auth) are injected via initializers or a DI container (Swift's @Environment, Resolver, or manual composition root) rather than reached via singletons inside business logic. This is what makes unit testing and SwiftUI previews possible without hitting the network. Bad is a view model that calls NetworkManager.shared.fetch(...) directly, making it untestable without a live server.

9. Take a singleton census

Grep for static let shared and static var shared across the codebase and count them. Each one is a piece of implicit global state that couples otherwise-unrelated code and complicates testing and concurrency reasoning under Swift's strict concurrency checking. Bad is 30+ singletons, several of which are mutable and touched from background queues.

10. Review version pinning strategy

Check whether Package.swift/Podfile uses exact versions, up-to-next-major, or floating ranges, and whether Package.resolved/Podfile.lock is committed. Unpinned or loosely pinned dependencies mean CI builds aren't reproducible and a transitive update can break the app without a code change. Bad is a Package.resolved that isn't checked into git at all.

3. Build performance

11. Measure clean vs. incremental build times in CI

Track both clean and incremental build durations as a CI metric (Xcode's -resultBundlePath plus a build-time-tracking script, or a service like BuildTime) rather than relying on individual developers' anecdotal "it feels slow." At scale, a clean build climbing past 15-20 minutes directly taxes CI cost and PR turnaround. Bad is a build time nobody has measured since the project was small.

12. Find type-checking hotspots

Run builds with -Xfrontend -warn-long-function-bodies=100 and -Xfrontend -warn-long-expression-type-checking=100 to surface functions where Swift's type inference blows up, especially complex SwiftUI view bodies or chained collection operations. These hotspots can single-handedly add minutes to incremental builds. Bad is a SwiftUI view with a 200-line body and no explicit return types, timing out the type checker.

13. Check module parallelization

Verify the module graph is wide enough for Xcode's build system to compile targets in parallel (visible in the build timeline in Xcode's Report Navigator or xcodebuild -showBuildTimingSummary). A deep, linear dependency chain forces serial builds even on a 16-core machine. Bad is one giant app target with no internal module boundaries at all, so nothing can build in parallel.

14. Audit code generation cost

Check the build-time contribution of SwiftGen, Sourcery, R.swift, or SwiftUI's Xcode previews/macros by timing a build with and without the generation step. Code generation that runs on every build (not just when source changes) silently adds minutes at scale. Bad is a Sourcery template re-running unconditionally on every incremental build instead of being cached.

15. Review debug build configuration hygiene

Confirm Debug builds use -Onone optimization, whole-module optimization is off, and unnecessary sanitizers/instrumentation aren't enabled by default for every developer. Misconfigured debug settings (e.g., accidentally inheriting Release optimization flags) can double local build times for the whole team. Bad is a debug scheme that still has "Whole Module Optimization" or bitcode-era settings left over from a template nobody revisited.

4. Runtime performance

16. Set and measure a cold start budget

Define a target time-to-interactive (e.g., under 400ms to first frame) and measure it with Xcode's App Launch instrument or MetricKit's MXAppLaunchMetric in production. Cold start is the first performance signal every user experiences, and regressions creep in silently as more gets initialized in AppDelegate/App.init(). Bad is a launch path that synchronously configures five SDKs before the first screen renders.

17. Check for main-thread I/O

Profile with Instruments' Time Profiler or the Thread Sanitizer/Main Thread Checker enabled in CI to catch synchronous disk or network calls on the main thread. Even occasional main-thread I/O produces hitches that compound at scale across every screen that does it. Bad is Data(contentsOf:) or a synchronous Core Data fetch called from viewDidLoad.

18. Track memory growth under navigation cycles

Use Instruments' Allocations/Leaks tool to push a screen on and off the navigation stack repeatedly and watch for memory that doesn't return to baseline. Retain cycles in closures (missing [weak self]) or delegate references are the usual culprit and compound into OOM kills for long user sessions. Bad is memory climbing 5-10MB per navigation cycle with no plateau.

19. Audit the image and asset pipeline

Check whether images are downsampled to display size before decoding (using CGImageSourceCreateThumbnailAtIndex or a library like Kingfisher/SDWebImage with proper resizing) rather than decoded at full resolution and scaled in a UIImageView. Undownsampled images are one of the most common causes of memory spikes and scroll jank. Bad is a 4000x3000 camera photo decoded full-size to fill a 100x100 thumbnail.

20. Instrument scroll performance

Measure frame rate during scrolling with Instruments' Core Animation tool or MTKView/CADisplayLink-based custom instrumentation, targeting a consistent 120fps on ProMotion devices or 60fps minimum elsewhere. Dropped frames during scroll are highly visible to users and often trace back to expensive cellForRow/view-body work happening synchronously. Bad is cell configuration that does layout math or image decoding directly on the main thread during scroll.

5. Testing seams

21. Measure the unit-testable business logic ratio

Estimate what fraction of business logic lives in plain Swift types (not view controllers or SwiftUI views) using code coverage reports from xcodebuild test -enableCodeCoverage YES broken out by target. Logic trapped inside view layer code can only be exercised through slow UI tests or not at all. Bad is a codebase where 80% of coverage comes from massive view controllers that mix networking, validation, and layout.

22. Check network layer fakeability

Verify the networking layer is defined behind a protocol that can be swapped for a fake/mock in tests, rather than tests hitting URLSession.shared directly or relying on URLProtocol stubbing everywhere. A fakeable network seam is what makes fast, deterministic unit tests possible instead of flaky integration tests. Bad is a test suite that requires a live staging server to pass.

23. Track UI test flake rate

Pull historical CI results (Xcode Cloud, Bitrise, or GitHub Actions test reports) and compute the retry/failure rate for the UI test suite over the last 30 days. A flake rate above a few percent trains engineers to ignore red CI, which defeats the purpose of having the suite. Bad is a UI test suite that requires "just re-run it" as a documented step in the README.

24. Measure test execution time

Track total test suite wall-clock time in CI and flag when it crosses a threshold that discourages running it locally (generally a few minutes for unit tests). Slow test suites get skipped, which means regressions ship. Bad is a "unit test" target that takes 20+ minutes because it's secretly full of UI or integration tests.

25. Audit snapshot test coverage of the design system

Check whether shared UI components (buttons, cards, design-system primitives) have snapshot tests via a library like swift-snapshot-testing, covering both light/dark mode and Dynamic Type sizes. Without this, every design-system change requires manual visual QA across the whole app. Bad is a design system with zero snapshot coverage, so a padding change silently breaks twelve screens.

6. Release engineering

26. Review release cadence and automation

Check how much of the release process (build, sign, upload to App Store Connect, changelog) is automated via Fastlane, Xcode Cloud, or a similar CI/CD pipeline versus done by hand. Manual release steps don't scale past a small team and are a common source of last-minute release-day fires. Bad is a release process that lives in one engineer's head and a personal keychain.

27. Track crash-free session rate against a defined threshold

Pull crash-free session percentage from Crashlytics, Sentry, or MetricKit's MXCrashDiagnostic and check whether the team has an actual threshold (e.g., 99.5%) that blocks releases when missed. A number nobody is accountable to is decoration, not a metric. Bad is a crash dashboard that exists but has no alerting or release gate tied to it.

28. Verify a rollback strategy exists

Confirm there's a documented, tested path to pull a bad release — phased rollout halts in App Store Connect, a kill switch via remote config, or an expedited review process. Apple's review time means you can't always "just ship a fix," so the rollback plan has to not depend on App Store review. Bad is "we'd submit an emergency fix and hope for expedited review."

29. Check feature flagging coverage

Verify risky new features ship behind a remote-config/feature-flag system (LaunchDarkly, Firebase Remote Config, or a custom solution) rather than being gated only by App Store release. Flags decouple "shipped to the App Store" from "turned on for users," which is what makes safe rollout and fast rollback possible. Bad is every feature going straight to 100% of users the moment a build is approved.

30. Audit dSYM and symbolication hygiene

Check that dSYMs are automatically uploaded to the crash reporting service on every release build (via Fastlane's upload_symbols_to_crashlytics or equivalent) and that a spot-check crash from the last release symbolicates cleanly. Missing dSYMs turn crash reports into useless hex addresses right when you need them most. Bad is a backlog of unsymbolicated crashes because dSYM upload silently failed three releases ago.

7. Security

31. Verify Keychain is used for secrets, not UserDefaults

Grep for UserDefaults.standard.set near anything that looks like a token, password, or credential, and confirm secrets are instead stored via Security framework Keychain APIs (or a wrapper like KeychainAccess). UserDefaults is stored in a plist that's trivially readable on a jailbroken device or from a backup. Bad is an auth token sitting in UserDefaults under a key like "authToken".

32. Review certificate pinning decisions

Check whether the app pins certificates or public keys for sensitive endpoints via URLSessionDelegate's urlSession(_:didReceive:completionHandler:) or a library like TrustKit, and whether that decision was deliberate versus copy-pasted. Pinning protects against MITM attacks on compromised networks but also risks bricking connectivity on cert rotation if not paired with a rollover plan. Bad is pinning implemented once, years ago, with no process for rotating pins before certs expire.

33. Assess jailbreak/tamper posture against actual threat model

Check whether the app has jailbreak detection, anti-debugging, or binary integrity checks, and whether that investment matches what the app actually protects (payments and identity data warrant more than a social feed does). Over-investing here wastes engineering time; under-investing on a high-value target (fintech, identity) is a real exposure. Bad is a security control copied from a template with no threat model behind the decision.

34. Audit third-party SDK data collection

Review each third-party SDK's network calls (using Charles Proxy or Proxyman to capture traffic) and cross-reference against the app's privacy manifest (PrivacyInfo.xcprivacy, required by Apple since 2024) to confirm declared data use matches actual behavior. Undisclosed data collection by an ad or analytics SDK is both a user-trust problem and an App Store rejection risk. Bad is an SDK phoning home with device identifiers that never made it into the privacy manifest.

35. Inventory ATS exceptions

Search Info.plist for NSAppTransportSecurity and NSExceptionDomains entries and justify each one — every ATS exception is a domain allowed to communicate without TLS 1.2+/forward secrecy guarantees. At scale, ATS exceptions accumulate from third-party SDK requirements and are rarely revisited once added. Bad is a blanket NSAllowsArbitraryLoads: true added once to unblock a build and never removed.

8. Team scalability

36. Build a code ownership map

Generate a CODEOWNERS file or use git log --format='%ae' -- <path> to identify who actually touches each module, and check for modules with a single owner. This surfaces both accountability gaps (nobody reviews changes here) and bus-factor risk. Bad is a critical payments module where git blame shows one person authored 95% of it and they're the only reviewer who understands it.

37. Measure PR review latency

Pull time-to-first-review and time-to-merge from GitHub/GitLab's API or a tool like Haystack/LinearB, broken out by module or team. Review latency over a day or two starts to compound into stale branches, merge conflicts, and slower shipping at scale. Bad is a median time-to-first-review of 3+ days with no owner accountable for it.

38. Time onboarding to first shipped change

Track how long it takes a new engineer to land their first merged PR, from repo clone to production, and treat regressions in that number as a signal of rising complexity or documentation debt. This is a leading indicator that build setup, module structure, or tribal knowledge have outgrown what's written down. Bad is a new hire's first PR taking three weeks because the local build setup isn't documented anywhere.

39. Check for architecture decision records

Look for an ADR directory (docs/adr/ or similar) documenting why significant architectural choices were made, not just what the current state is. Without ADRs, every "why is it built this way" question requires finding whoever was in the room three years ago. Bad is an architecture that contradicts itself in two places with no record of which decision is current.

40. Assess knowledge bus-factor for critical subsystems

For each critical subsystem (auth, payments, the build pipeline itself), identify how many engineers could debug a production incident in it without paging the original author, using the ownership map from item 36 plus informal team surveys. A bus factor of one on anything load-bearing is an organizational risk, not just a technical one. Bad is a build pipeline that only one engineer, who left six months ago, ever fully understood.

MIT License. Use, share, and adapt freely with attribution.

About

A 40 item checklist that ensures your iOS app is locked and loaded to scale up to the big leagues

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors