Why Architecture Matters More in SwiftUI Than UIKit
Here is a take that might surprise you: architecture matters more in SwiftUI than it did in UIKit, not less. I know the marketing pitch is that SwiftUI is simpler. And it is — for small apps. But the moment your app grows past a handful of screens, the same forces that made UIKit apps messy hit SwiftUI apps twice as hard.
In UIKit, the view controller was a natural boundary. It had a lifecycle, it owned its views, and even if it became a "Massive View Controller," you could at least find everything in one file. SwiftUI does not have that boundary. Views are structs. They are cheap to create, easy to nest, and tempting to stuff with logic. Before you know it, your ContentView has 400 lines, makes network calls directly, manages navigation state, and holds business logic that should live nowhere near a view.
The other problem is state management. SwiftUI's reactivity is powerful — @State, @Binding, @Observable, @Environment — but with power comes the chance to create spaghetti state that is impossible to debug. When a view re-renders and you do not know why, or when two views disagree about the current state, the root cause is almost always an architecture problem, not a SwiftUI bug.
Good architecture in SwiftUI is about drawing clear lines: views render state, view models manage state, models define state, and services fetch and persist state. Let me show you exactly how to set this up.
MVVM the SwiftUI Way (Not the UIKit Way)
Most MVVM tutorials for SwiftUI are written by developers who learned MVVM in UIKit and carried their patterns over. The result is over-engineered view models that fight SwiftUI's reactive system instead of working with it. Let me show you what MVVM actually looks like when you design it for SwiftUI from scratch.
The Model Layer: Plain Structs
Models are the simplest layer. They are plain Swift structs — Codable, Sendable, Identifiable where needed. No business logic, no networking, no state management. Just data.
// Models/UserProfile.swift
import Foundation
struct UserProfile: Codable, Sendable, Identifiable {
let id: UUID
var displayName: String
var email: String
var avatarURL: URL?
var bio: String
let createdAt: Date
var isPro: Bool
static let placeholder = UserProfile(
id: UUID(),
displayName: "Loading...",
email: "",
avatarURL: nil,
bio: "",
createdAt: Date(),
isPro: false
)
}
struct Post: Codable, Sendable, Identifiable {
let id: UUID
let authorID: UUID
var title: String
var body: String
var tags: [String]
let createdAt: Date
var updatedAt: Date
var isPublished: Bool
}Notice the placeholder static property. This is a pattern I use everywhere — it gives views something to render in loading states without dealing with optionals. It keeps your view code cleaner than littering it with if let unwrapping.
The ViewModel Layer: @MainActor + @Observable
As of Swift 5.10 and the @Observable macro (which replaced ObservableObject), view models have become significantly simpler. No more @Published property wrappers on every field. The @Observable macro generates the observation tracking automatically.
// ViewModels/ProfileViewModel.swift
import SwiftUI
@MainActor
@Observable
final class ProfileViewModel {
// MARK: - State
var profile: UserProfile = .placeholder
var posts: [Post] = []
var isLoading = false
var errorMessage: String?
// MARK: - Dependencies
private let userRepository: UserRepositoryProtocol
private let postRepository: PostRepositoryProtocol
// MARK: - Init
init(
userRepository: UserRepositoryProtocol,
postRepository: PostRepositoryProtocol
) {
self.userRepository = userRepository
self.postRepository = postRepository
}
// MARK: - Actions
func loadProfile(userID: UUID) async {
isLoading = true
errorMessage = nil
do {
async let fetchedProfile = userRepository.getProfile(id: userID)
async let fetchedPosts = postRepository.getPosts(authorID: userID)
profile = try await fetchedProfile
posts = try await fetchedPosts
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
func updateBio(_ newBio: String) async {
profile.bio = newBio // Optimistic update
do {
try await userRepository.updateProfile(profile)
} catch {
errorMessage = "Failed to save bio. Please try again."
// Revert optimistic update
await loadProfile(userID: profile.id)
}
}
func deletePost(_ post: Post) async {
posts.removeAll { $0.id == post.id } // Optimistic removal
do {
try await postRepository.deletePost(id: post.id)
} catch {
errorMessage = "Failed to delete post."
await loadProfile(userID: profile.id)
}
}
}Key things to notice:
@MainActoron the class. This ensures all property mutations happen on the main thread. No moreDispatchQueue.main.asyncsprinkled through your code. No more "Publishing changes from background threads" warnings.@Observableinstead ofObservableObject. The new observation system is more efficient — views only re-render when the specific properties they read change, not when any@Publishedproperty changes. This is a genuine performance improvement for complex views.- Dependencies are injected via init. The view model does not create its own repositories. It receives them. This makes testing trivial — pass in a mock repository and verify behavior.
- Optimistic updates with rollback. Update the UI immediately, then sync with the server. If the sync fails, revert. This makes the app feel instant while still being correct.
async letfor parallel fetches. Profile and posts load simultaneously, not sequentially. This cuts perceived loading time roughly in half.
The View Layer: Render State, Dispatch Actions
// Views/ProfileView.swift
import SwiftUI
struct ProfileView: View {
@State private var viewModel: ProfileViewModel
let userID: UUID
init(userID: UUID, container: DIContainer) {
self.userID = userID
_viewModel = State(
initialValue: ProfileViewModel(
userRepository: container.userRepository,
postRepository: container.postRepository
)
)
}
var body: some View {
ScrollView {
if viewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity, minHeight: 200)
} else {
VStack(alignment: .leading, spacing: 24) {
profileHeader
postsList
}
.padding()
}
}
.overlay {
if let error = viewModel.errorMessage {
ErrorBanner(message: error) {
viewModel.errorMessage = nil
}
}
}
.task { await viewModel.loadProfile(userID: userID) }
.refreshable { await viewModel.loadProfile(userID: userID) }
.navigationTitle(viewModel.profile.displayName)
}
private var profileHeader: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 16) {
AsyncImage(url: viewModel.profile.avatarURL) { image in
image.resizable().scaledToFill()
} placeholder: {
Circle().fill(.gray.opacity(0.3))
}
.frame(width: 72, height: 72)
.clipShape(Circle())
VStack(alignment: .leading) {
Text(viewModel.profile.displayName)
.font(.title2.bold())
Text(viewModel.profile.email)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
Text(viewModel.profile.bio)
.font(.body)
.foregroundStyle(.secondary)
}
}
private var postsList: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Posts")
.font(.headline)
ForEach(viewModel.posts) { post in
PostRow(post: post) {
Task { await viewModel.deletePost(post) }
}
}
}
}
}The view does three things and only three things: renders state, dispatches actions to the view model, and composes child views. There is zero business logic, zero networking, and zero data transformation in the view. If you find yourself writing if statements that decide what to do (not just what to show), that logic belongs in the view model.
Dependency Injection Without Frameworks
You do not need Swinject, Factory, or any third-party DI framework. Swift's protocol system and a simple container struct give you everything you need. Here is the approach I use in every production app.
Step 1: Define Protocols for Every Service
// Protocols/Repositories.swift
import Foundation
protocol UserRepositoryProtocol: Sendable {
func getProfile(id: UUID) async throws -> UserProfile
func updateProfile(_ profile: UserProfile) async throws
func deleteAccount(id: UUID) async throws
}
protocol PostRepositoryProtocol: Sendable {
func getPosts(authorID: UUID) async throws -> [Post]
func createPost(_ post: Post) async throws -> Post
func updatePost(_ post: Post) async throws
func deletePost(id: UUID) async throws
}
protocol AuthServiceProtocol: Sendable {
var currentUserID: UUID? { get async }
func signInWithApple(idToken: String) async throws
func signOut() async throws
}
protocol AnalyticsServiceProtocol: Sendable {
func track(_ event: String, properties: [String: String])
func identify(userID: String)
}Step 2: Build the DI Container
// DI/DIContainer.swift
import Foundation
import Supabase
@MainActor
final class DIContainer: Sendable {
let supabaseClient: SupabaseClient
let userRepository: UserRepositoryProtocol
let postRepository: PostRepositoryProtocol
let authService: AuthServiceProtocol
let analytics: AnalyticsServiceProtocol
let featureFlags: FeatureFlagService
init(configuration: AppConfiguration) {
// Create the Supabase client once
self.supabaseClient = SupabaseClient(
supabaseURL: configuration.supabaseURL,
supabaseKey: configuration.supabaseAnonKey
)
// Wire up real implementations
self.userRepository = SupabaseUserRepository(client: supabaseClient)
self.postRepository = SupabasePostRepository(client: supabaseClient)
self.authService = SupabaseAuthService(client: supabaseClient)
self.analytics = MixpanelAnalyticsService(token: configuration.mixpanelToken)
self.featureFlags = FeatureFlagService()
}
/// Creates a container with mock implementations for previews and tests
static func mock() -> DIContainer {
let config = AppConfiguration.mock
let container = DIContainer(configuration: config)
return container
}
}
struct AppConfiguration {
let supabaseURL: URL
let supabaseAnonKey: String
let mixpanelToken: String
static let production = AppConfiguration(
supabaseURL: URL(string: "https://yourproject.supabase.co")!,
supabaseAnonKey: "your-anon-key",
mixpanelToken: "your-mixpanel-token"
)
static let mock = AppConfiguration(
supabaseURL: URL(string: "http://localhost:54321")!,
supabaseAnonKey: "mock-key",
mixpanelToken: ""
)
}Step 3: Inject via SwiftUI Environment
// DI/EnvironmentKeys.swift
import SwiftUI
private struct DIContainerKey: EnvironmentKey {
static let defaultValue: DIContainer? = nil
}
extension EnvironmentValues {
var container: DIContainer? {
get { self[DIContainerKey.self] }
set { self[DIContainerKey.self] = newValue }
}
}
// App entry point
@main
struct MyApp: App {
@State private var container = DIContainer(configuration: .production)
var body: some Scene {
WindowGroup {
RootView()
.environment(\.container, container)
}
}
}This approach gives you three things that matter: testability (swap real implementations for mocks), preview support (use mock data without hitting your server), and a single place to wire up all your dependencies. No runtime reflection, no magic, no framework lock-in. Just protocols, structs, and Swift's type system.
For more on setting up your project structure with all of this wired correctly from the start, see our SwiftUI boilerplate guide.
The Repository Pattern: Abstract Your Backend
The repository pattern is the single most important architectural pattern for any app that talks to a backend. It gives you a protocol that defines what data operations your app needs, and concrete implementations that define how those operations are performed.
Here is a complete example with a Supabase implementation and a local (mock/preview) implementation:
Protocol Definition
// Protocols/PostRepositoryProtocol.swift (already defined above)
protocol PostRepositoryProtocol: Sendable {
func getPosts(authorID: UUID) async throws -> [Post]
func createPost(_ post: Post) async throws -> Post
func updatePost(_ post: Post) async throws
func deletePost(id: UUID) async throws
}Supabase Implementation
// Repositories/SupabasePostRepository.swift
import Foundation
import Supabase
struct SupabasePostRepository: PostRepositoryProtocol {
let client: SupabaseClient
func getPosts(authorID: UUID) async throws -> [Post] {
try await client
.from("posts")
.select()
.eq("author_id", value: authorID.uuidString)
.order("created_at", ascending: false)
.execute()
.value
}
func createPost(_ post: Post) async throws -> Post {
try await client
.from("posts")
.insert(post)
.select()
.single()
.execute()
.value
}
func updatePost(_ post: Post) async throws {
try await client
.from("posts")
.update(post)
.eq("id", value: post.id.uuidString)
.execute()
}
func deletePost(id: UUID) async throws {
try await client
.from("posts")
.delete()
.eq("id", value: id.uuidString)
.execute()
}
}Local / Mock Implementation
// Repositories/LocalPostRepository.swift
import Foundation
actor LocalPostRepository: PostRepositoryProtocol {
private var posts: [Post] = Post.sampleData
func getPosts(authorID: UUID) async throws -> [Post] {
// Simulate network delay for realistic previews
try await Task.sleep(for: .milliseconds(300))
return posts.filter { $0.authorID == authorID }
.sorted { $0.createdAt > $1.createdAt }
}
func createPost(_ post: Post) async throws -> Post {
try await Task.sleep(for: .milliseconds(200))
posts.append(post)
return post
}
func updatePost(_ post: Post) async throws {
try await Task.sleep(for: .milliseconds(200))
guard let index = posts.firstIndex(where: { $0.id == post.id }) else {
throw RepositoryError.notFound
}
posts[index] = post
}
func deletePost(id: UUID) async throws {
try await Task.sleep(for: .milliseconds(200))
posts.removeAll { $0.id == id }
}
}
enum RepositoryError: LocalizedError {
case notFound
case unauthorized
var errorDescription: String? {
switch self {
case .notFound: return "Resource not found"
case .unauthorized: return "You are not authorized to perform this action"
}
}
}The power of this pattern becomes obvious when you need to switch backends. If you ever migrate from Supabase to Firebase, or add offline support with SwiftData, you write a new implementation of the same protocol. Your view models, views, and business logic do not change at all. For a real-world example of the Supabase side, see our Supabase SwiftUI tutorial.
Feature Flags Without a Service
Feature flags are essential for shipping safely. You want to deploy code behind flags, enable features for a percentage of users, and kill-switch anything that goes wrong — without pushing an app update. Here is a lightweight implementation that does not require LaunchDarkly or any third-party service:
// Services/FeatureFlagService.swift
import Foundation
import Supabase
@MainActor
@Observable
final class FeatureFlagService {
private(set) var flags: [String: Bool] = [:]
private var defaults: [String: Bool] = [
"enable_ai_chat": false,
"enable_pro_paywall_v2": false,
"enable_social_features": true,
"enable_dark_mode_auto": true,
"enable_push_notifications": true,
]
func loadFlags(client: SupabaseClient) async {
do {
let remoteFlags: [FeatureFlag] = try await client
.from("feature_flags")
.select()
.execute()
.value
var merged = defaults
for flag in remoteFlags {
merged[flag.key] = flag.enabled
}
flags = merged
} catch {
// Fall back to defaults if network fails
flags = defaults
}
}
func isEnabled(_ key: String) -> Bool {
flags[key] ?? defaults[key] ?? false
}
}
struct FeatureFlag: Codable {
let key: String
let enabled: Bool
}
// Usage in views:
// if container.featureFlags.isEnabled("enable_ai_chat") {
// AIChatButton()
// }This pulls flags from a simple feature_flags table in your Supabase database. You can update flags in real-time from the Supabase dashboard without any app update. The defaults dictionary ensures the app works offline or if the fetch fails. For more complex scenarios — percentage rollouts, user targeting, A/B tests — you can extend the model with a rolloutPercentage field or use the flags in combination with Remote Config.
Architecture Comparison: MVVM vs TCA vs Clean Architecture
MVVM is not the only option. Here is how the three most popular SwiftUI architectures compare:
| Criteria | MVVM + Repository | TCA (Composable Architecture) | Clean Architecture (VIPER-ish) |
|---|---|---|---|
| Complexity | Low to medium — 3 layers, intuitive naming | High — Reducers, State, Action, Environment, Effects | High — Entities, Use Cases, Interactors, Presenters, Routers |
| Learning Curve | 1-2 weeks for a junior developer | 3-6 weeks; functional programming concepts required | 2-4 weeks; lots of boilerplate to internalize |
| Testability | Good — mock repositories via protocols | Excellent — deterministic state testing with TestStore | Excellent — every layer is independently testable |
| Boilerplate | Low — one ViewModel per feature, protocols for DI | Medium — Reducer, State, Action, Feature structs per feature | High — 5-7 files per feature (Entity, UseCase, Repository, Presenter, View, Router) |
| Team Scale | 1-5 developers — easy to onboard, easy to maintain | 3-15 developers — strict patterns prevent divergence on large teams | 5-20 developers — clear boundaries reduce merge conflicts |
| SwiftUI Fit | Natural — @Observable and @State work as designed | Good but opinionated — TCA has its own view binding system | Awkward — designed for UIKit, adapted for SwiftUI with friction |
| Navigation | SwiftUI native (NavigationStack, NavigationPath) | TCA's own navigation system with path-based routing | Router/Coordinator pattern — powerful but complex |
| Best For | Indie devs, small teams, startups, most apps | Teams that value strict unidirectional data flow and testing | Large enterprise apps with complex business logic layers |
My recommendation: start with MVVM + Repository. It covers 90% of iOS apps. If you are on a team of 10+ developers building a complex app and you find state management becoming chaotic, evaluate TCA. If you are building an enterprise banking app with 50 screens and complex business rules, consider Clean Architecture. But do not reach for complexity before you need it.
TCA is a genuinely impressive framework, and Point-Free has done incredible work. But I have seen too many solo developers and small teams adopt it prematurely, spend weeks learning the patterns, and then struggle to ship features at the pace they need. Architecture exists to serve shipping speed and code quality. If it is slowing you down, you picked the wrong one for your context.
Common Anti-Patterns to Avoid
After reviewing hundreds of SwiftUI codebases (both open-source and from developers who emailed me asking for help), these are the patterns that cause the most pain:
Anti-Pattern 1: Fat Views
The most common mistake. Your view should not contain business logic, network calls, data transformation, or complex state management. If your view body is longer than 40-50 lines, extract subviews. If your view file is longer than 150 lines, you probably need a view model.
// BAD: Networking in the view
struct FeedView: View {
@State private var posts: [Post] = []
var body: some View {
List(posts) { post in
Text(post.title)
}
.task {
// This does not belong here
let url = URL(string: "https://api.example.com/posts")!
let (data, _) = try! await URLSession.shared.data(from: url)
posts = try! JSONDecoder().decode([Post].self, from: data)
}
}
}
// GOOD: View just renders and delegates
struct FeedView: View {
@State private var viewModel: FeedViewModel
var body: some View {
List(viewModel.posts) { post in
Text(post.title)
}
.task { await viewModel.loadPosts() }
}
}Anti-Pattern 2: Singleton Services
Singletons are tempting because they are easy. AuthService.shared, NetworkManager.shared, DatabaseService.shared. The problems surface later: you cannot test them (they hold global mutable state), you cannot run two instances side by side (needed for testing), and they create hidden dependencies that make your code hard to reason about.
// BAD: Singleton
class AuthService {
static let shared = AuthService()
private init() {}
// ...
}
// GOOD: Protocol + injection
protocol AuthServiceProtocol: Sendable {
func signIn(email: String, password: String) async throws
}
class AuthService: AuthServiceProtocol {
// No static shared. Created in DIContainer and injected.
}Anti-Pattern 3: Ignoring Sendable
Swift 6's strict concurrency checking is not optional anymore. If you are ignoring Sendablewarnings today, you are building technical debt that will block your migration to Swift 6 strict mode. Mark your models as Sendable. Use actor for mutable shared state. Use @MainActor for view models. The compiler is trying to help you — let it.
Anti-Pattern 4: Overusing @EnvironmentObject
@EnvironmentObject (or @Environment with custom keys) is great for truly global state like the DI container, color scheme, or locale. It is terrible for feature-specific state. If only one screen needs a view model, pass it directly — do not inject it into the environment just because you can. Over-using the environment makes it impossible to understand which views depend on which state.
Anti-Pattern 5: Not Using .task for Async Work
I still see developers using .onAppear with Task { inside it. Use .task instead. It automatically cancels the task when the view disappears, preventing work from continuing after the user navigates away. It also handles the view's lifecycle correctly in ways that manual Task creation does not.
// BAD: Manual Task management
.onAppear {
Task {
await viewModel.load() // Not cancelled if view disappears
}
}
// GOOD: Automatic lifecycle management
.task {
await viewModel.load() // Cancelled when view disappears
}Project Structure: Putting It All Together
Here is the folder structure I use for production SwiftUI apps. It scales from a 5-screen MVP to a 50-screen app without reorganization:
MyApp/
├── App/
│ ├── MyApp.swift // @main entry point
│ └── AppConfiguration.swift // Environment-specific config
├── DI/
│ ├── DIContainer.swift // Dependency wiring
│ └── EnvironmentKeys.swift // SwiftUI environment extensions
├── Models/
│ ├── UserProfile.swift
│ ├── Post.swift
│ └── FeatureFlag.swift
├── Protocols/
│ ├── UserRepositoryProtocol.swift
│ ├── PostRepositoryProtocol.swift
│ └── AuthServiceProtocol.swift
├── Repositories/
│ ├── Supabase/
│ │ ├── SupabaseUserRepository.swift
│ │ └── SupabasePostRepository.swift
│ └── Local/
│ ├── LocalUserRepository.swift
│ └── LocalPostRepository.swift
├── Services/
│ ├── AuthService.swift
│ ├── AnalyticsService.swift
│ └── FeatureFlagService.swift
├── ViewModels/
│ ├── ProfileViewModel.swift
│ ├── FeedViewModel.swift
│ └── SettingsViewModel.swift
├── Views/
│ ├── Profile/
│ │ ├── ProfileView.swift
│ │ └── EditProfileSheet.swift
│ ├── Feed/
│ │ ├── FeedView.swift
│ │ └── PostRow.swift
│ ├── Auth/
│ │ ├── SignInView.swift
│ │ └── OnboardingView.swift
│ └── Shared/
│ ├── ErrorBanner.swift
│ ├── LoadingOverlay.swift
│ └── EmptyStateView.swift
└── Resources/
├── Assets.xcassets
└── Localizable.xcstringsThis is not dogma — adapt it to your needs. The point is that every file has an obvious home, and a new developer joining the project can find anything in under 10 seconds. If you want a project that comes with this structure already set up, with Supabase, auth, navigation, and all the architecture wired correctly from day one, check out The Swift Kit. It is the boilerplate I wish I had when I started my first serious SwiftUI project.
Testing Your Architecture
Good architecture makes testing almost effortless. Here is a unit test for the ProfileViewModelusing the mock repository:
// Tests/ProfileViewModelTests.swift
import XCTest
@testable import MyApp
@MainActor
final class ProfileViewModelTests: XCTestCase {
private var mockUserRepo: MockUserRepository!
private var mockPostRepo: MockPostRepository!
private var viewModel: ProfileViewModel!
override func setUp() {
mockUserRepo = MockUserRepository()
mockPostRepo = MockPostRepository()
viewModel = ProfileViewModel(
userRepository: mockUserRepo,
postRepository: mockPostRepo
)
}
func testLoadProfileSuccess() async {
let testUser = UserProfile(
id: UUID(),
displayName: "Test User",
email: "test@example.com",
avatarURL: nil,
bio: "Hello",
createdAt: Date(),
isPro: false
)
mockUserRepo.stubbedProfile = testUser
mockPostRepo.stubbedPosts = [
Post(id: UUID(), authorID: testUser.id, title: "Post 1",
body: "Body", tags: [], createdAt: Date(),
updatedAt: Date(), isPublished: true)
]
await viewModel.loadProfile(userID: testUser.id)
XCTAssertEqual(viewModel.profile.displayName, "Test User")
XCTAssertEqual(viewModel.posts.count, 1)
XCTAssertFalse(viewModel.isLoading)
XCTAssertNil(viewModel.errorMessage)
}
func testLoadProfileFailure() async {
mockUserRepo.shouldThrow = true
await viewModel.loadProfile(userID: UUID())
XCTAssertNotNil(viewModel.errorMessage)
XCTAssertFalse(viewModel.isLoading)
}
func testDeletePostOptimisticUpdate() async {
let postID = UUID()
let post = Post(id: postID, authorID: UUID(), title: "To Delete",
body: "Body", tags: [], createdAt: Date(),
updatedAt: Date(), isPublished: true)
viewModel.posts = [post]
await viewModel.deletePost(post)
XCTAssertTrue(viewModel.posts.isEmpty)
}
}
// Mocks
class MockUserRepository: UserRepositoryProtocol {
var stubbedProfile: UserProfile = .placeholder
var shouldThrow = false
func getProfile(id: UUID) async throws -> UserProfile {
if shouldThrow { throw RepositoryError.notFound }
return stubbedProfile
}
func updateProfile(_ profile: UserProfile) async throws {
if shouldThrow { throw RepositoryError.notFound }
}
func deleteAccount(id: UUID) async throws {}
}
class MockPostRepository: PostRepositoryProtocol {
var stubbedPosts: [Post] = []
var shouldThrow = false
func getPosts(authorID: UUID) async throws -> [Post] {
if shouldThrow { throw RepositoryError.notFound }
return stubbedPosts
}
func createPost(_ post: Post) async throws -> Post { post }
func updatePost(_ post: Post) async throws {}
func deletePost(id: UUID) async throws {}
}Notice how clean these tests are. No network stubs, no mocking frameworks, no flaky async waits. You set up the mock, call the method, assert the result. The protocol-based DI makes this possible. Each test runs in milliseconds and is fully deterministic.
Final Thoughts: Architecture Is a Spectrum
The goal of architecture is not perfection. It is sustainable velocity — the ability to ship features quickly today without creating problems that slow you down tomorrow. MVVM with protocol-based dependency injection and the repository pattern gives you a strong foundation without drowning in abstraction.
Start simple. Add layers only when you feel pain. If your view model is getting too big, extract a service. If you are copy-pasting network code, create a repository. If multiple features need the same data, create a shared store. Let the codebase tell you what it needs rather than pre-architecting for problems you do not have yet.
For a reference implementation of everything covered in this guide — MVVM, DI container, repository pattern, feature flags, and a clean project structure — check out The Swift Kit. It implements all of these patterns with Supabase, Sign in with Apple, RevenueCat, and a complete navigation system already wired up. See the features page for the full technical breakdown, or grab it from pricing and start building on a solid foundation. We also have detailed docs explaining every architectural decision so you understand the "why" behind every pattern.