learn()
{ start }
build++
const tutorial

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.

Cuppa Team4 min read
iosswiftarchitectureswiftuimvvmclean-architecture

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

  1. Separation of Concerns: Each layer has a single, well-defined responsibility
  2. Dependency Rule: Inner layers don't know about outer layers
  3. Unidirectional Data Flow: Data flows in one direction, reducing complexity
  4. 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

Related Resources