Modern iOS App Architecture - Part 1: Foundation & Layers
Deep dive into modern iOS app architecture, covering layered architecture, MVVM, unidirectional data flow, and dependency injection. Essential guide for building scalable iOS applications with SwiftUI.
This is Part 1 of our comprehensive Modern iOS App Architecture series. We'll cover the foundational principles, layered architecture, and key components that form the basis of scalable iOS applications.
Architectural Overview
Modern iOS architecture follows a layered approach with clear separation of concerns:
┌────────────────────────────────┐
│ Presentation Layer │ ← UI (SwiftUI) + ViewModels
├────────────────────────────────┤
│ Domain Layer │ ← Use Cases + Domain Models
├────────────────────────────────┤
│ Data Layer │ ← Repositories + Data Sources
└────────────────────────────────┘
Key Principles
- Separation of Concerns: Each layer has a single, well-defined responsibility
- Dependency Rule: Inner layers don't know about outer layers
- Unidirectional Data Flow: Data flows in one direction, reducing complexity
- Single Source of Truth: Each piece of data has a single source
Layer Breakdown
1. Presentation Layer
The UI layer observes and displays data from the domain layer using SwiftUI and Combine.
ViewModel with @Published
@MainActor
final class ProfileViewModel: ObservableObject {
@Published private(set) var profile: UserProfile?
@Published private(set) var isLoading = false
@Published private(set) var error: Error?
private let getUserProfileUseCase: GetUserProfileUseCase
private let updateProfileUseCase: UpdateProfileUseCase
init(
getUserProfileUseCase: GetUserProfileUseCase,
updateProfileUseCase: UpdateProfileUseCase
) {
self.getUserProfileUseCase = getUserProfileUseCase
self.updateProfileUseCase = updateProfileUseCase
}
func loadProfile() async {
isLoading = true
error = nil
do {
profile = try await getUserProfileUseCase.execute()
} catch {
self.error = error
}
isLoading = false
}
func updateProfile(_ updates: ProfileUpdates) async {
do {
try await updateProfileUseCase.execute(updates)
await loadProfile()
} catch {
self.error = error
}
}
}
SwiftUI View
struct ProfileView: View {
@StateObject private var viewModel: ProfileViewModel
var body: some View {
Group {
if viewModel.isLoading {
ProgressView()
} else if let profile = viewModel.profile {
ProfileContent(profile: profile)
} else if let error = viewModel.error {
ErrorView(error: error) {
Task {
await viewModel.loadProfile()
}
}
}
}
.task {
await viewModel.loadProfile()
}
}
}
2. Domain Layer
The domain layer contains business logic and domain models.
Use Case
protocol GetUserProfileUseCase {
func execute() async throws -> UserProfile
}
final class GetUserProfileUseCaseImpl: GetUserProfileUseCase {
private let repository: UserRepository
init(repository: UserRepository) {
self.repository = repository
}
func execute() async throws -> UserProfile {
let profile = try await repository.getUserProfile()
// Business logic validation
guard profile.isValid else {
throw ProfileError.invalidProfile
}
return profile
}
}
Domain Model
struct UserProfile: Identifiable {
let id: String
let email: String
let name: String
let avatarURL: URL?
let createdAt: Date
var isValid: Bool {
!email.isEmpty && !name.isEmpty
}
}
3. Data Layer
The data layer manages data sources and provides a unified interface.
Repository
protocol UserRepository {
func getUserProfile() async throws -> UserProfile
func updateProfile(_ updates: ProfileUpdates) async throws
}
final class UserRepositoryImpl: UserRepository {
private let remoteDataSource: RemoteDataSource
private let localDataSource: LocalDataSource
init(
remoteDataSource: RemoteDataSource,
localDataSource: LocalDataSource
) {
self.remoteDataSource = remoteDataSource
self.localDataSource = localDataSource
}
func getUserProfile() async throws -> UserProfile {
do {
let profile = try await remoteDataSource.fetchUserProfile()
try await localDataSource.save(profile)
return profile
} catch {
// Fallback to cached data
return try await localDataSource.getUserProfile()
}
}
func updateProfile(_ updates: ProfileUpdates) async throws {
try await remoteDataSource.updateProfile(updates)
try await localDataSource.updateProfile(updates)
}
}
Dependency Injection
Use Swift's dependency injection for managing dependencies.
DIContainer
final class DIContainer {
static let shared = DIContainer()
private init() {}
// MARK: - Data Layer
lazy var remoteDataSource: RemoteDataSource = {
RemoteDataSourceImpl(apiClient: apiClient)
}()
lazy var localDataSource: LocalDataSource = {
LocalDataSourceImpl()
}()
lazy var userRepository: UserRepository = {
UserRepositoryImpl(
remoteDataSource: remoteDataSource,
localDataSource: localDataSource
)
}()
// MARK: - Domain Layer
lazy var getUserProfileUseCase: GetUserProfileUseCase = {
GetUserProfileUseCaseImpl(repository: userRepository)
}()
lazy var updateProfileUseCase: UpdateProfileUseCase = {
UpdateProfileUseCaseImpl(repository: userRepository)
}()
// MARK: - Presentation Layer
func makeProfileViewModel() -> ProfileViewModel {
ProfileViewModel(
getUserProfileUseCase: getUserProfileUseCase,
updateProfileUseCase: updateProfileUseCase
)
}
}
Best Practices
1. Use @MainActor for ViewModels
@MainActor
final class ViewModel: ObservableObject {
// All UI updates automatically on main thread
}
2. Handle Cancellation
func loadData() async {
do {
let data = try await fetchData()
self.data = data
} catch is CancellationError {
// Handle cancellation silently
return
} catch {
self.error = error
}
}
3. Use Protocols for Testability
protocol DataSource {
func fetch() async throws -> Data
}
// Easy to mock in tests
final class MockDataSource: DataSource {
func fetch() async throws -> Data {
return mockData
}
}
Next Steps
In Part 2, we'll explore:
- State management patterns
- Navigation coordination
- Error handling strategies
- Testing approaches