Building Scalable SwiftUI Architecture
How to structure your SwiftUI apps for maintainability and scalability using proven architectural patterns.
Published on December 15, 2024 • 8 min read
Introduction
As SwiftUI applications grow in complexity, having a solid architectural foundation becomes crucial for maintainability, testability, and team collaboration. After working on several production SwiftUI apps over the past few years, I've learned valuable lessons about what works and what doesn't when it comes to structuring SwiftUI applications.
In this post, I'll share the architectural patterns and best practices that have served me well in building scalable SwiftUI applications. We'll cover MVVM implementation, dependency injection, state management, and how to organize your codebase for maximum maintainability.
The MVVM Pattern in SwiftUI
SwiftUI naturally encourages the MVVM (Model-View-ViewModel) pattern, but implementing it correctly requires careful consideration. The key is to keep your views simple and push all business logic into observable view models.
Here's how I structure a typical view model:
@MainActor
class UserProfileViewModel: ObservableObject {
    @Published var user: User?
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    private let userService: UserServiceProtocol
    
    init(userService: UserServiceProtocol) {
        self.userService = userService
    }
    
    func loadUser(id: String) async {
        isLoading = true
        errorMessage = nil
        
        do {
            user = try await userService.fetchUser(id: id)
        } catch {
            errorMessage = error.localizedDescription
        }
        
        isLoading = false
    }
}
Notice how the view model is marked with @MainActor to ensure all UI updates happen on the main thread, and how dependencies are injected through the initializer rather than created internally.
Dependency Injection
Dependency injection is crucial for testability and loose coupling. I prefer using a simple container-based approach that leverages SwiftUI's environment system:
class DIContainer: ObservableObject {
    lazy var userService: UserServiceProtocol = UserService()
    lazy var authService: AuthServiceProtocol = AuthService()
    lazy var networkService: NetworkServiceProtocol = NetworkService()
}

// Environment key
private struct DIContainerKey: EnvironmentKey {
    static let defaultValue = DIContainer()
}

extension EnvironmentValues {
    var diContainer: DIContainer {
        get { self[DIContainerKey.self] }
        set { self[DIContainerKey.self] = newValue }
    }
}
This approach allows you to inject dependencies at the app level and access them throughout your view hierarchy using the @Environment property wrapper.
State Management Strategies
State management in SwiftUI can be tricky, especially in larger applications. I follow these principles:
1. Single Source of Truth: Each piece of data should have one authoritative source. Use @StateObject for view models that own the data, and @ObservedObject for view models passed down from parent views.
2. Minimize State: Keep state as local as possible. Only lift state up when multiple views need to share it.
3. Use App State for Global Data: For truly global state like user authentication or app settings, consider using a dedicated app state object:
@MainActor
class AppState: ObservableObject {
    @Published var isAuthenticated = false
    @Published var currentUser: User?
    @Published var networkStatus: NetworkStatus = .unknown
    
    // App-wide actions and state updates
}
Code Organization
A well-organized codebase is essential for team collaboration and long-term maintainability. Here's the folder structure I recommend:
YourApp/
├── App/
│ ├── YourApp.swift
│ └── AppState.swift
├── Core/
│ ├── DI/
│ ├── Network/
│ └── Extensions/
├── Features/
│ ├── Authentication/
│ │ ├── Views/
│ │ ├── ViewModels/
│ │ └── Models/
│ └── UserProfile/
│ ├── Views/
│ ├── ViewModels/
│ └── Models/
└── Resources/
    ├── Assets.xcassets
    └── Localizable.strings
This feature-based organization makes it easy to find related code and promotes modularity. Each feature contains its own views, view models, and models, making it easier to reason about and test.
Testing Your Architecture
A well-architected SwiftUI app should be easy to test. By using dependency injection and keeping business logic in view models, you can write comprehensive unit tests:
@MainActor
class UserProfileViewModelTests: XCTestCase {
    func testLoadUserSuccess() async throws {
        // Given
        let mockService = MockUserService()
        let viewModel = UserProfileViewModel(userService: mockService)
        
        // When
        await viewModel.loadUser(id: "123")
        
        // Then
        XCTAssertNotNil(viewModel.user)
        XCTAssertFalse(viewModel.isLoading)
        XCTAssertNil(viewModel.errorMessage)
    }
}
Conclusion
Building scalable SwiftUI architecture requires thoughtful planning and consistent application of best practices. By following MVVM patterns, implementing proper dependency injection, managing state effectively, and organizing your code well, you'll create applications that are maintainable, testable, and enjoyable to work with.
Remember that architecture should serve your team and project needs. Start simple and evolve your architecture as your application grows. The patterns I've shared here have worked well for medium to large-scale iOS applications, but always consider your specific context and requirements.
Have questions about SwiftUI architecture? Feel free to reach out – I'd love to discuss these patterns and hear about your experiences building SwiftUI applications.
← Back to Blog Portfolio