The Swift Kit logoThe Swift Kit
Guide

SwiftUI App Architecture in 2026: MVVM and Dependency Injection Done Right

Architecture advice that actually works for SwiftUI — not UIKit patterns forced into a declarative framework. Covers MVVM with @Observable, protocol-based dependency injection, the repository pattern for backend abstraction, feature flags, and the anti-patterns that will sink your codebase.

Ahmed GaganAhmed Gagan
15 min read

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:

  • @MainActor on the class. This ensures all property mutations happen on the main thread. No more DispatchQueue.main.async sprinkled through your code. No more "Publishing changes from background threads" warnings.
  • @Observable instead of ObservableObject. The new observation system is more efficient — views only re-render when the specific properties they read change, not when any @Published property 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 let for 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:

CriteriaMVVM + RepositoryTCA (Composable Architecture)Clean Architecture (VIPER-ish)
ComplexityLow to medium — 3 layers, intuitive namingHigh — Reducers, State, Action, Environment, EffectsHigh — Entities, Use Cases, Interactors, Presenters, Routers
Learning Curve1-2 weeks for a junior developer3-6 weeks; functional programming concepts required2-4 weeks; lots of boilerplate to internalize
TestabilityGood — mock repositories via protocolsExcellent — deterministic state testing with TestStoreExcellent — every layer is independently testable
BoilerplateLow — one ViewModel per feature, protocols for DIMedium — Reducer, State, Action, Feature structs per featureHigh — 5-7 files per feature (Entity, UseCase, Repository, Presenter, View, Router)
Team Scale1-5 developers — easy to onboard, easy to maintain3-15 developers — strict patterns prevent divergence on large teams5-20 developers — clear boundaries reduce merge conflicts
SwiftUI FitNatural — @Observable and @State work as designedGood but opinionated — TCA has its own view binding systemAwkward — designed for UIKit, adapted for SwiftUI with friction
NavigationSwiftUI native (NavigationStack, NavigationPath)TCA's own navigation system with path-based routingRouter/Coordinator pattern — powerful but complex
Best ForIndie devs, small teams, startups, most appsTeams that value strict unidirectional data flow and testingLarge 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.xcstrings

This 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.

Share this article

Ready to ship your iOS app faster?

The Swift Kit gives you a production-ready SwiftUI codebase with onboarding, paywalls, auth, AI integrations, and more. Stop building boilerplate. Start building your product.

Get The Swift Kit