Swift App Development: Concurrency, Testing and Codebases That Last
How we write Swift that stays maintainable: data-race-safe concurrency, tests that earn their keep, modular packages, and incremental migration from Objective-C.
Most apps do not fail because of the language they are written in. They fail because the codebase becomes too expensive to change. Swift app development, done well, keeps that cost low: types that describe the domain precisely, concurrency the compiler can check, fast tests, and modules with honest boundaries.
Good looks like this: a new engineer can find where a feature lives, a change to one feature rebuilds only that feature, data races show up as compile errors instead of crash reports, and regressions are caught before a build reaches TestFlight. If you are still deciding on platform strategy, start with our overview of iOS app development. Here, the focus is the code itself.
What we use Swift for
- Native apps for iPhone, iPad, Apple Watch, Apple TV and the Mac, with shared Swift packages for models, networking and the design system.
- App extensions such as widgets, notification service extensions and share extensions, all reusing the same core modules.
- SDKs you ship to partners, where a stable public API and binary compatibility matter more than elegance inside.
- Existing Swift codebases that need new features, stability work or structural repair.
- Objective-C apps that need modernizing, gradually and without a feature freeze.
Swift also runs on Linux servers, with mature frameworks such as Vapor and Hummingbird. It is a reasonable choice when your team is Swift-heavy, but for most products we still recommend a mainstream stack for the backend, where hiring and hosting are simpler.
Writing Swift the compiler can check
Types that carry the design
Swift’s type system is its best maintenance tool when it is used deliberately:

- Value types for state. Structs and enums make data flow predictable, and an enum with associated values can make invalid states impossible: a screen is loading, loaded with data, or failed with an error, never a confusing mix spread across several optionals.
- Protocols at the boundaries. Network, storage, clock and analytics sit behind narrow protocols that tests can swap for fakes. Inside a feature, concrete types are fine; a protocol for every class is ceremony.
- Generics without over-abstraction. Opaque types by default, and existential types only where you genuinely need mixed values, since those cost runtime performance and lose type information.
- Errors that mean something. Untyped throws by default, typed throws for narrow, closed sets of failures such as a parser, and no silently swallowed errors in business logic, where they turn into support tickets.
- Resilient decoding. When the backend adds a new enum case, an older app version should degrade gracefully, not fail to decode the entire response. We handle unknown values explicitly and test decoding against real payloads.
- Macros with restraint. Macros such as the one behind Observation remove boilerplate, but custom macros add build time and a learning curve. We write our own only when the payoff is clear.
Concurrency without data races
Async/await retired the callback pyramid, but the bigger change is safety. Structured concurrency ties child tasks to their parent, so canceling the parent, for example when someone leaves a screen, cancels the work beneath it. Actors protect mutable state, the main actor owns the UI, and Sendable marks what may safely cross between them. With strict concurrency checking on, a data race becomes a compile error. Recent Swift releases can also make a module default to main-actor isolation, which suits UI code and removes most annotation noise.
The pitfalls we look for in code review:
- Actor reentrancy. An actor’s state can change at every await. Code that checks a condition, awaits, then acts on the old check is a race in slow motion.
- Orphaned tasks. A task started in a view model and never canceled keeps working after the screen is gone. Tying work to a view’s lifetime with SwiftUI’s task modifier avoids that.
- Escape hatches. Unchecked Sendable conformances and unsafe nonisolated state are sometimes necessary, but each needs a comment explaining why it is actually safe.
- A busy main actor. Synchronous file, image or JSON work that blocks the UI belongs elsewhere.
For existing code, we adopt strict checking one module at a time, warnings first, with preconcurrency imports for dependencies that are not annotated yet. Combine still works where it is used. New code tends toward async sequences and Observation, but we do not rewrite working Combine pipelines for fashion.
Testing that pays for itself
New tests use Swift Testing: readable expectations, parameterized tests that replace copy-pasted cases, and parallel execution by default, which quickly exposes shared mutable state in the code under test. XCTest remains for UI automation and performance tests, and the two frameworks coexist in the same target.
What we test, roughly in order of value:
- Domain logic and state machines: pricing, eligibility, validation, sync conflict rules.
- View models, with injected clocks, identifiers and network responses, so tests are deterministic and never sleep.
- Decoding against recorded API payloads, and database migrations against real older schemas.
- Snapshots of design-system components across text sizes and appearance modes.
- A thin layer of UI tests for flows that must never break, such as sign-in and purchase.
Packages that do not depend on UIKit can run their tests directly on a Mac without booting a simulator, which keeps the feedback loop short. Beyond automated tests, our QA testers check usability, performance and compatibility across devices and OS versions, because a green test suite says nothing about whether a flow makes sense to a person.
Modularization without the ceremony
We split an app into local Swift packages in one repository: core models and utilities, networking, persistence, a design system, and one module per feature area. The app target becomes a thin layer that wires them together. That buys faster incremental builds, faster previews, boundaries the compiler enforces, fewer merge conflicts between engineers, and code that extensions and other platforms can reuse.
It has costs. Too many tiny modules add build-graph overhead and friction for small changes, and dynamic frameworks slow app launch, so we link statically unless there is a reason not to. A module per feature area and per shared capability is the right grain; a module per screen is not. Access control does real work here: internal by default, public only for deliberate API, and package access for code shared between modules of the same package. CI runs a formatter and a linter, fails on new warnings, and a dead-code scan keeps unused code from piling up.
Migrating from Objective-C, one piece at a time
A big-bang rewrite of a working Objective-C app usually means a long stretch without new features and a fresh set of bugs. Mixed codebases work well, so we migrate incrementally:

- Add nullability annotations and lightweight generics to Objective-C headers, so Swift sees accurate optionals and typed collections.
- Write all new features in Swift.
- Convert leaf code first, such as models and utilities, then services, and screens last.
- Pin down behavior with characterization tests before converting anything that holds business rules.
- Give frequently used Objective-C APIs proper Swift names, and expose Swift back to Objective-C only where needed.
Some constraints shape the plan. A Swift package target cannot mix Swift and Objective-C sources, so mixed code stays in the app target or is split into single-language packages. Swift-only constructs such as structs, enums with associated values and generic types are invisible to Objective-C, so shared interfaces stay simple until both sides are Swift. Old runtime tricks like method swizzling need careful review, and if the project still depends on CocoaPods, moving to Swift Package Manager belongs early in the plan. For C++ cores, Swift’s direct C++ interoperability often removes the need for an Objective-C++ wrapper layer.
When native Swift app development is the right choice
Native Swift is the right choice for Apple-first products, apps that depend on deep platform integration or demanding performance, products spanning several Apple devices, and codebases expected to live for years. It is also right when you already have a Swift or Objective-C app that works and needs a future rather than a replacement.
It is not the right choice for every product. If you need iOS and Android at the same time with shared UI and a small team, React Native is often the better trade. If you want to share business logic with Android but keep native UI on both, Kotlin Multiplatform with SwiftUI on the Apple side is a credible middle path, and our native mobile app development article covers the wider native-versus-cross-platform decision.
For existing apps, we usually start with a focused codebase audit: build times, crash and hang data, concurrency warnings, test coverage of critical paths, dependency health, and a security review of secrets handling, Keychain use and network settings. You receive a written report with prioritized recommendations. Then we either fix things alongside your team or take the work over.
Frequently asked questions
Should we rewrite our Objective-C app in Swift?
Rarely all at once. Migrate incrementally, starting with the parts that change most often, and let stable Objective-C code keep working until there is a reason to touch it.
Is Swift the same thing as SwiftUI?
No. Swift is the language; SwiftUI and UIKit are interface frameworks you use from Swift. A codebase can be modern, well-tested Swift with UIKit screens, and many good ones are.
How much work is strict concurrency checking for an existing app?
It depends on how much shared mutable state the code has and how it is modularized. We estimate it after an audit, then adopt it module by module so feature work continues in parallel.
Can you work with our in-house iOS engineers?
Yes. We can pair on a migration, review pull requests, set up CI and modules, and leave your team owning code they understand.
What does a Swift codebase audit give us?
A written picture of where time and risk go: build performance, crash and hang hotspots, concurrency and memory issues, test gaps, outdated dependencies and security findings, each with a recommended fix and a rough sense of effort.
Whether you are starting a new Swift codebase or rescuing one, tell us about your product and the state of its code. We will tell you honestly what we would change first.