iOS Design Patterns That Actually Hold Up in 2026

The patterns that hold up are MVC for simple screens, MVVM for testable presentation logic, and Coordinator for navigation. What changed in 2026 is Swift 6 concurrency: mark view models @MainActor, keep models Sendable, and pass dependencies through protocols so tests run without a live network stack.
Part of the complete app development guide.
On this page
- 1. MVC (Model-View-Controller), used honestly
- 2. MVVM (Model-View-ViewModel)
- 3. Coordinator (or Router)
- 4. Repository
- 5. Observer (and where it overlaps with async/await)
- How to actually choose
- FAQ
- Is MVVM still worth it in 2026, or should I use something newer?
- Do I need a Coordinator if I’m using SwiftUI’s NavigationStack?
- Should I use Combine or async/await for observers?
Most “design pattern” posts list five patterns, define them badly, and never show you where they break. I’ve shipped enough Swift to know the useful question isn’t “what is MVVM” but “when does MVVM start fighting me.” So here’s the version I’d want if I were picking an architecture for an app I have to maintain for three years.
Swift 6’s strict concurrency changed the calculus a bit. Main-actor isolation, Sendable checking, and async/await are now baked into how these patterns get written, so some of the old advice from 2019 does not compile cleanly anymore. I’ll flag where that matters.

1. MVC (Model-View-Controller), used honestly
UIKit and SwiftUI both give you MVC out of the box. The pattern is fine. The problem is that “Controller” becomes the dumping ground for networking, formatting, navigation, and business rules until you have a 900-line view controller nobody wants to touch.
MVC earns its keep on small apps, prototypes, and screens with genuinely simple logic. If your screen just loads data and shows it, a fat controller is not a crisis. The moment you start writing unit tests and can’t instantiate the controller without a live network stack, that’s your signal to move on.

2. MVVM (Model-View-ViewModel)
MVVM pulls presentation logic out of the view and into a view model you can test without a UI. In SwiftUI it fits naturally because the view is already a function of state.
@MainActor
@Observable
final class ArticleListViewModel {
private let repo: ArticleRepository
private(set) var articles: [Article] = []
private(set) var isLoading = false
var errorMessage: String?
init(repo: ArticleRepository) {
self.repo = repo
}
func load() async {
isLoading = true
defer { isLoading = false }
do {
articles = try await repo.fetchAll()
} catch {
errorMessage = "Couldn't load articles."
}
}
}
Note the @Observable macro (iOS 17+) instead of ObservableObject and @Published. It’s less boilerplate and it only tracks the properties a view actually reads. If you’re still on iOS 16, stick with ObservableObject.
Where MVVM goes wrong: view models that import SwiftUI, view models that hold navigation state, and view models that talk to each other. Keep them dumb. They take input, produce output, and expose a state enum. That’s it.
3. Coordinator (or Router)
Navigation is the thing that wrecks most MVVM codebases. A view model pushes a screen, now it knows about the view layer, now you can’t test it, now you have a retain cycle.
The Coordinator pattern pulls navigation into its own object. The view model emits an intent, the coordinator decides what screen comes next.
@MainActor
protocol ArticleListCoordinator: AnyObject {
func showArticle(_ article: Article)
}
@MainActor
final class ArticleListViewModel {
private weak var coordinator: ArticleListCoordinator?
private let repo: ArticleRepository
init(repo: ArticleRepository, coordinator: ArticleListCoordinator) {
self.repo = repo
self.coordinator = coordinator
}
func didSelect(_ article: Article) {
coordinator?.showArticle(article)
}
}
Weak reference on the coordinator, always. I’ve debugged that leak more than once. For SwiftUI, you can get most of this with NavigationStack and a typed path, but the coordinator still helps when flows span multiple tabs or need to be reused.
4. Repository
A repository hides where data comes from. The rest of your app asks for articles, not for a URL session and a Core Data stack.
protocol ArticleRepository: Sendable {
func fetchAll() async throws -> [Article]
}
final class LiveArticleRepository: ArticleRepository {
private let client: APIClient
private let cache: ArticleCache
init(client: APIClient, cache: ArticleCache) {
self.client = client
self.cache = cache
}
func fetchAll() async throws -> [Article] {
if let cached = await cache.load() { return cached }
let fresh = try await client.get("/articles", as: [Article].self)
await cache.save(fresh)
return fresh
}
}
The payoff is testability and swap-ability. In tests you pass a stub. When you move from REST to GraphQL, only the live implementation changes. Keep the protocol small. A repository with fifteen methods is a sign you’re wrapping an ORM, not hiding a data source.
5. Observer (and where it overlaps with async/await)
Observer is how you react to changes you don’t control: notification center events, KVO, or your own publisher. In 2026 you have three options and you should pick deliberately.
- NotificationCenter for system-wide events with no clear owner. Fine for things like “user signed out.”
- AsyncStream when you want an async sequence you can
for awaitover. This is the modern default. - Combine when you’re already in a Combine codebase or need its operators. Apple is not killing it, but it’s not where new code should start.
func articleUpdates() -> AsyncStream<Article> {
AsyncStream { continuation in
let task = Task {
for await note in NotificationCenter.default.notifications(named: .articleUpdated) {
if let article = note.object as? Article {
continuation.yield(article)
}
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
Cancel the task when the stream terminates or you’ll leak the observer. This is the same class of bug as forgetting to remove a KVO observer, just with different syntax.
How to actually choose
Don’t adopt all five because a blog post said so. A reasonable default for a new SwiftUI app in 2026:
- MVVM with
@Observableview models for screen logic. - One repository per data domain, protocol-first.
- Coordinator or a typed
NavigationStackpath for anything beyond a two-screen flow. - AsyncStream for events that cross module boundaries.
- MVC only where the screen is genuinely trivial.
Add patterns when the pain shows up, not before. If you’re building a client-facing app and want a second opinion on the architecture, that’s the kind of thing I do as a Long Island web design and mobile developer. Most of the value is in the boring decisions: naming, boundaries, and not over-abstracting on day one.
FAQ
Is MVVM still worth it in 2026, or should I use something newer?
Still worth it. MVVM isn’t trendy, it’s just a clean separation that works with SwiftUI’s state model. Newer patterns like TCA are fine, but they add real complexity, and you should only pay for that if your app has the coordination problems to justify it.
Do I need a Coordinator if I’m using SwiftUI’s NavigationStack?
Not always. If your navigation is a straight push and pop within one stack, a typed path is enough. Reach for a Coordinator when flows span tabs, need to be reused, or when you want the navigation logic unit tested independently of the views.
Should I use Combine or async/await for observers?
Start with async/await and AsyncStream. It integrates with the rest of the concurrency model and is easier to reason about. Combine still has its place in existing codebases, but I wouldn’t reach for it on a greenfield project in 2026.


