iOS Engineering · Swift · Apple Platform

iOS Clean Architecture SwiftUI · TCA · async/await · SwiftData

The definitive field guide for building scalable, testable, and delightful iOS applications — from Clean Architecture principles to Apple-idiomatic implementation.

TCA / MVVM SwiftUI async/await SwiftData Keychain CryptoKit Combine URLSession SFSpeechRecognizer AVSpeechSynthesizer Network.framework Swift Package Manager
🖼️
Presentation
SwiftUI
Views · ViewModels · Reducers · Navigation — no business logic
↓ observes state, dispatches actions ↓
⚙️
Domain
Pure Swift
Use Cases · Entities · Repository protocols — zero UIKit/SwiftUI imports
↓ calls protocol, data layer implements ↓
💾
Data
I/O
Repositories · URLSession · SwiftData · Keychain · DTOs + mappers
↓ wired by ↓
🔧
Infrastructure
Core
DI container · Network client · CryptoKit · Logging · Config
↓ async services ↓
🎙️
Platform Services
Apple SDK
SFSpeechRecognizer · AVSpeechSynthesizer · Network.framework · Push
↓ cross-cutting ↓
🔐
Security
AES·Keychain
Keychain Services · CryptoKit AES-256-GCM · SSL pinning · Biometrics

01 — Architecture

The Layer Contract

Clean Architecture on iOS — dependencies point inward. SwiftUI views depend on ViewModels. ViewModels depend on Use Cases. Use Cases depend on Repository protocols. Concrete implementations live in the Data layer.

The iOS golden rule: The Domain layer has zero UIKit, SwiftUI, or framework imports. Use Cases are plain Swift classes with async throws functions. Repository interfaces are Swift protocols — any concrete implementation can be swapped. This makes every use case unit-testable without mocking the framework.
🖼️
Presentation
SwiftUI Views are pure functions of state. ViewModels (ObservableObject / @Observable) hold UI state and call use cases. TCA Reducers handle actions and produce state mutations. No direct networking or persistence calls.
⚙️
Domain
Pure Swift. Use cases are single-responsibility callable structs. Entities are value types (structs). Repository protocols defined here — Data layer conforms to them. The heart of the app — testable without any mocks.
💾
Data
Implements Repository protocols. URLSession for network. SwiftData / Core Data for persistence. Keychain for secure storage. DTOs map to domain entities via mappers. Offline-first via cache-then-network pattern.
🔧
Infrastructure
Dependency injection via Swift's environment system or a lightweight container. NetworkClient factory with interceptors. AES-256-GCM encryption via CryptoKit. Structured logging via OSLog. Feature flags.
🎙️
Platform Services
Apple framework wrappers — SFSpeechRecognizer for STT, AVSpeechSynthesizer for TTS, Network.framework for WebSocket, UserNotifications for push. Each wrapped in a protocol for testability.
🔐
Security
Keychain for tokens, user data, biometric keys. CryptoKit for AES-256-GCM request/response encryption. SSL certificate pinning via URLSessionDelegate. LocalAuthentication for Face ID / Touch ID.

02 — Project Structure

Feature-first Modules

Organised by feature, not by layer. Each feature owns its slice of presentation, domain, and data. Shared infrastructure lives at the root. Swift Package Manager manages all dependencies.

MyApp.xcodeproj / Sources /
Features/— one group per domain feature
Auth/
Presentation/
LoginView.swiftSwiftUI
LoginViewModel.swift@Observable
AuthReducer.swiftTCA— if using TCA
Domain/
User.swift— domain entity (struct)
AuthRepositoryProtocol.swift— protocol
LoginUseCase.swift
LogoutUseCase.swift
Data/
AuthRepositoryImpl.swiftRepo
AuthRemoteDataSource.swift— URLSession
UserDTO.swift— Codable DTO + mapper
Tests/
LoginUseCaseTests.swiftUnit
LoginViewTests.swiftUI
Products/— same structure, every feature
Presentation/ Domain/ Data/ Tests/
Chat/— WebSocket realtime
ChatView.swift
ChatViewModel.swift
WebSocketService.swiftNetwork.fw
Core/— shared infrastructure
Network/
NetworkClient.swift— URLSession wrapper
RequestInterceptor.swift— auth + retry
EncryptionInterceptor.swiftCryptoKit
SSLPinningDelegate.swiftSecurity
Security/
KeychainService.swiftKeychain
CryptoService.swiftAES-256
BiometricService.swift— Face ID / Touch ID
Speech/
SpeechToTextService.swiftSFSpeech
TextToSpeechService.swiftAVFoundation
DI/
DependencyContainer.swift— or use TCA Dependencies
AppError.swift
Logger.swift— OSLog structured logging
App/
MyApp.swift— @main entry, DI bootstrap
AppRouter.swift— NavigationStack coordinator
Package.swift— SPM dependencies

03 — Code Patterns

Swift Blueprints

Production-ready Swift 6. Every pattern is async/await-native, Sendable-safe, and compiles with strict concurrency enabled.

User.swift — domain entitydomain
// Pure Swift — zero framework imports
struct User: Identifiable, Equatable, Sendable {
    let id:        String
    let email:     String
    var name:      String
    var role:      UserRole
    let createdAt: Date
}

enum UserRole: String, Codable, Sendable {
    case admin, user, moderator
}

// Repository protocol — domain owns the contract
protocol AuthRepositoryProtocol: Sendable {
    func login(email: String, password: String)
        async throws -> AuthTokens
    func logout() async throws
    func getCurrentUser() async throws -> User?
    func refreshTokens() async throws -> AuthTokens
}

struct AuthTokens: Sendable {
    let accessToken:  String
    let refreshToken: String
    let expiresAt:    Date
}
LoginUseCase.swift — SOLID SRPdomain
// Callable use case — one responsibility
struct LoginUseCase: Sendable {
    private let authRepo:     any AuthRepositoryProtocol
    private let keychainSvc:  KeychainService

    init(
        authRepo:    any AuthRepositoryProtocol,
        keychainSvc: KeychainService,
    ) {
        self.authRepo    = authRepo
        self.keychainSvc = keychainSvc
    }

    func callAsFunction(
        email:    String,
        password: String,
    ) async throws -> User {
        // 1. Validate locally before hitting network
        guard email.contains("@") else {
            throw AppError.validationFailed("Invalid email")
        }

        // 2. Authenticate via repository
        let tokens = try await authRepo.login(
            email: email, password: password
        )

        // 3. Persist tokens securely
        try keychainSvc.save(tokens)

        // 4. Fetch and return user profile
        return try await authRepo.getCurrentUser()!
    }
}

// Call like a function: try await loginUseCase(email:password:)
AppError.swift — typed error hierarchydomain
enum AppError: LocalizedError, Equatable {
    case networkError(URLError)
    case serverError(statusCode: Int, message: String)
    case unauthorized
    case notFound(String)
    case validationFailed(String)
    case keychainError(OSStatus)
    case decryptionFailed
    case unknown

    var errorDescription: String? {
        switch self {
        case .unauthorized:
            return "Session expired. Please log in again."
        case .serverError(let code, let msg):
            return "Server error \(code): \(msg)"
        case .validationFailed(let reason):
            return reason
        default:
            return "An unexpected error occurred."
        }
    }
}
LoginViewModel.swift — @Observablepresentation
@Observable
@MainActor
final class LoginViewModel {
    // State
    var email    = ""
    var password = ""
    var isLoading = false
    var error: AppError? = nil
    var user: User? = nil

    // Debounce search via async task
    private var searchTask: Task<Void, Never>?

    private let loginUseCase: LoginUseCase

    init(loginUseCase: LoginUseCase) {
        self.loginUseCase = loginUseCase
    }

    func login() {
        guard !isLoading else { return }
        isLoading = true
        error = nil
        Task {
            do {
                user = try await loginUseCase(
                    email: email, password: password
                )
            } catch let appError as AppError {
                error = appError
            } catch {
                self.error = .unknown
            }
            isLoading = false
        }
    }

    // Debounced search — cancels previous task
    func onSearchChanged(_ query: String) {
        searchTask?.cancel()
        searchTask = Task {
            try? await Task.sleep(for: .milliseconds(350))
            guard !Task.isCancelled else { return }
            await performSearch(query)
        }
    }
}
LoginView.swift — SwiftUIpresentation
struct LoginView: View {
    @State private var viewModel: LoginViewModel
    @Environment(\.dismiss) private var dismiss
    @Namespace private var namespace  // shared element

    var body: some View {
        ScrollView {
            VStack(spacing: 24) {
                // Matched geometry source
                AppLogo()
                    .matchedGeometryEffect(
                        id: "logo",
                        in: namespace
                    )
                    .animation(.spring(duration: 0.5), value: viewModel.isLoading)

                TextField("Email", text: $viewModel.email)
                    .textContentType(.emailAddress)
                    .keyboardType(.emailAddress)
                    .autocorrectionDisabled()

                SecureField("Password", text: $viewModel.password)
                    .textContentType(.password)

                if let error = viewModel.error {
                    ErrorBanner(message: error.errorDescription!)
                        .transition(.move(edge: .top).combined(with: .opacity))
                }

                Button(action: viewModel.login) {
                    if viewModel.isLoading {
                        ProgressView()
                    } else {
                        Text("Sign In").fontWeight(.semibold)
                    }
                }
                .buttonStyle(.borderedProminent)
                .disabled(viewModel.isLoading)
            }
            .padding(24)
        }
        .animation(.easeInOut(duration: 0.25), value: viewModel.error)
    }
}
AppRouter.swift — NavigationStacknavigation
// Type-safe navigation with NavigationStack + enum
enum Route: Hashable {
    case login
    case home
    case productDetail(productId: String)
    case profile(userId: String)
    case settings
}

@Observable
@MainActor
final class AppRouter {
    var path = NavigationPath()

    func navigate(to route: Route) {
        path.append(route)
    }

    func pop() { path.removeLast() }
    func popToRoot() { path.removeLast(path.count) }
}

// Usage in root view
struct RootView: View {
    @State private var router = AppRouter()

    var body: some View {
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .productDetail(let id):
                        ProductDetailView(productId: id)
                    case .profile(let id):
                        ProfileView(userId: id)
                    case .settings:
                        SettingsView()
                    default: EmptyView()
                    }
                }
        }
        .environment(router)
    }
}
NetworkClient.swift — URLSession wrappernetwork
actor NetworkClient {
    private let session:  URLSession
    private let crypto:   CryptoService
    private let keychain: KeychainService
    private let baseURL:  URL

    func request<T: Decodable>(
        _ endpoint: Endpoint
    ) async throws -> T {
        var req = endpoint.urlRequest(baseURL: baseURL)

        // Attach Bearer token
        if let token = try? keychain.getAccessToken() {
            req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }

        // Encrypt request body (AES-256-GCM)
        if let body = req.httpBody {
            let encrypted = try crypto.encrypt(body)
            req.httpBody   = encrypted.ciphertext
            req.setValue(encrypted.ivBase64, forHTTPHeaderField: "X-IV")
        }

        let (data, response) = try await session.data(for: req)

        guard let http = response as? HTTPURLResponse else {
            throw AppError.unknown
        }

        switch http.statusCode {
        case 200...299:
            let decrypted = try crypto.decrypt(
                data,
                ivBase64: http.value(forHTTPHeaderField: "X-IV") ?? ""
            )
            return try JSONDecoder().decode(T.self, from: decrypted)
        case 401:  throw AppError.unauthorized
        default:   throw AppError.serverError(statusCode: http.statusCode, message: "")
        }
    }
}
Endpoint.swift + cancellationnetwork
struct Endpoint {
    let path:    String
    let method:  HTTPMethod
    let body:    (any Encodable)?
    let headers: [String: String]

    static func get(_ path: String) -> Endpoint {
        Endpoint(path: path, method: .get, body: nil, headers: [:])
    }
    static func post<B: Encodable>(_ path: String, body: B) -> Endpoint {
        Endpoint(path: path, method: .post, body: body, headers: [:])
    }
}

// Cancellable request — Task-based
class ProductsViewModel {
    private var searchTask: Task<Void, Never>?

    func search(_ query: String) {
        searchTask?.cancel()     // cancel previous in-flight
        searchTask = Task {
            do {
                try Task.checkCancellation()
                let results: [Product] = try await networkClient.request(
                    .get("/products?q=\(query)")
                )
                // Only update UI if not cancelled
                guard !Task.isCancelled else { return }
                await MainActor.run { self.products = results }
            } catch is CancellationError {
                // Expected — swallow silently
            } catch { handleError(error) }
        }
    }

    deinit { searchTask?.cancel() }
}
SSLPinningDelegate.swiftsecurity
final class SSLPinningDelegate: NSObject,
    URLSessionDelegate, Sendable {

    private let pinnedHashes: Set<String>

    init(pinnedHashes: Set<String>) {
        self.pinnedHashes = pinnedHashes
    }

    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge,
        completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
    ) {
        guard challenge.protectionSpace.authenticationMethod ==
              NSURLAuthenticationMethodServerTrust,
              let serverTrust = challenge.protectionSpace.serverTrust
        else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }

        let chain = SecTrustCopyCertificateChain(serverTrust)
            as? [SecCertificate] ?? []

        for cert in chain {
            let hash = sha256Hash(of: SecCertificateCopyData(cert) as Data)
            if pinnedHashes.contains(hash) {
                completionHandler(.useCredential, URLCredential(trust: serverTrust))
                return
            }
        }
        completionHandler(.cancelAuthenticationChallenge, nil)
    }
}
AuthRepositoryImpl.swift — offline firstdata
final class AuthRepositoryImpl: AuthRepositoryProtocol {
    private let remote:   AuthRemoteDataSource
    private let local:    AuthLocalDataSource   // SwiftData
    private let keychain: KeychainService

    func login(email: String, password: String) async throws -> AuthTokens {
        let dto    = try await remote.login(email: email, password: password)
        let tokens = dto.toTokens()
        // Write-through: persist user to SwiftData
        try await local.upsertUser(dto.user.toDomain())
        return tokens
    }

    func getCurrentUser() async throws -> User? {
        do {
            // Try network first
            let dto = try await remote.getProfile()
            let user = dto.toDomain()
            try await local.upsertUser(user)
            return user
        } catch {
            // Offline: serve cached data
            return try await local.getCurrentUser()
        }
    }
}
SwiftData model + local datasourcedata · SwiftData
import SwiftData

@Model
final class UserModel {
    @Attribute(.unique) var id:        String
    var email:     String
    var name:      String
    var role:      String
    var cachedAt:  Date

    init(from user: User) {
        id       = user.id
        email    = user.email
        name     = user.name
        role     = user.role.rawValue
        cachedAt = .now
    }

    func toDomain() -> User {
        User(id: id, email: email, name: name,
             role: UserRole(rawValue: role) ?? .user,
             createdAt: cachedAt)
    }
}

actor AuthLocalDataSource {
    private let container: ModelContainer

    func upsertUser(_ user: User) throws {
        let ctx = ModelContext(container)
        let model = UserModel(from: user)
        ctx.insert(model)
        try ctx.save()
    }

    func getCurrentUser() throws -> User? {
        let ctx = ModelContext(container)
        let desc = FetchDescriptor<UserModel>(
            sortBy: [.init(\.cachedAt, order: .reverse)]
        )
        return try ctx.fetch(desc).first?.toDomain()
    }
}
UserDTO.swift — Codable DTO + mapperdata
struct UserDTO: Decodable, Sendable {
    let id:        String
    let email:     String
    let name:      String
    let role:      String

    enum CodingKeys: String, CodingKey {
        case id, email, name, role
    }

    // Mapper — DTO ↔ Domain entity
    func toDomain() -> User {
        User(
            id:        id,
            email:     email,
            name:      name,
            role:      UserRole(rawValue: role) ?? .user,
            createdAt: .now
        )
    }
}

struct AuthResponseDTO: Decodable {
    let accessToken:  String
    let refreshToken: String
    let expiresIn:    Int
    let user:         UserDTO

    func toTokens() -> AuthTokens {
        AuthTokens(
            accessToken:  accessToken,
            refreshToken: refreshToken,
            expiresAt:    Date().addingTimeInterval(TimeInterval(expiresIn))
        )
    }
}
KeychainService.swift — tokenssecurity
import Security

final class KeychainService: Sendable {
    private let service = "com.myapp.tokens"

    func save(_ tokens: AuthTokens) throws {
        let data = try JSONEncoder().encode(tokens)
        let query: [CFString: Any] = [
            kSecClass:       kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: "auth_tokens",
            kSecValueData:   data,
            // Accessible only when device is unlocked
            kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
        ]
        SecItemDelete(query as CFDictionary)
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw AppError.keychainError(status)
        }
    }

    func getAccessToken() throws -> String? {
        let query: [CFString: Any] = [
            kSecClass:       kSecClassGenericPassword,
            kSecAttrService: service,
            kSecAttrAccount: "auth_tokens",
            kSecReturnData:  true,
        ]
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        guard status == errSecSuccess, let data = result as? Data else { return nil }
        let tokens = try JSONDecoder().decode(AuthTokens.self, from: data)
        return tokens.accessToken
    }

    func clearAll() {
        let query: [CFString: Any] = [kSecClass: kSecClassGenericPassword, kSecAttrService: service]
        SecItemDelete(query as CFDictionary)
    }
}
CryptoService.swift — AES-256-GCMsecurity · CryptoKit
import CryptoKit
import Foundation

struct EncryptedPayload {
    let ciphertext: Data
    let ivBase64:   String
}

actor CryptoService {
    private let key: SymmetricKey

    init(base64Secret: String) throws {
        guard let keyData = Data(base64Encoded: base64Secret),
              keyData.count == 32
        else { throw AppError.decryptionFailed }
        key = SymmetricKey(data: keyData)
    }

    func encrypt(_ data: Data) throws -> EncryptedPayload {
        let nonce     = try AES.GCM.Nonce()
        let sealed    = try AES.GCM.seal(data, using: key, nonce: nonce)
        let combined  = sealed.combined!
        let ivBase64  = Data(nonce).base64EncodedString()
        return EncryptedPayload(ciphertext: combined, ivBase64: ivBase64)
    }

    func decrypt(_ data: Data, ivBase64: String) throws -> Data {
        guard let ivData = Data(base64Encoded: ivBase64),
              let nonce = try? AES.GCM.Nonce(data: ivData)
        else { throw AppError.decryptionFailed }
        let box  = try AES.GCM.SealedBox(combined: data)
        return try AES.GCM.open(box, using: key)
    }
}
SpeechToTextService.swift — SFSpeechRecognizerplatform service
import Speech
import AVFoundation

@Observable
@MainActor
final class SpeechToTextService: NSObject {
    private let recognizer    = SFSpeechRecognizer(locale: .current)
    private let audioEngine   = AVAudioEngine()
    private var request:        SFSpeechAudioBufferRecognitionRequest?
    private var recognitionTask: SFSpeechRecognitionTask?

    var transcript = ""
    var isListening = false

    func requestPermission() async -> Bool {
        await withCheckedContinuation { cont in
            SFSpeechRecognizer.requestAuthorization { status in
                cont.resume(returning: status == .authorized)
            }
        }
    }

    func startListening() throws {
        stopListening()
        let session = AVAudioSession.sharedInstance()
        try session.setCategory(.record, mode: .measurement, options: .duckOthers)
        try session.setActive(true, options: .notifyOthersOnDeactivation)

        request = SFSpeechAudioBufferRecognitionRequest()
        request?.shouldReportPartialResults = true

        let inputNode = audioEngine.inputNode
        request!.addAudioPCMBuffer // installed below

        recognitionTask = recognizer?.recognitionTask(with: request!) { [weak self] result, err in
            if let result { self?.transcript = result.bestTranscription.formattedString }
        }

        let fmt = inputNode.outputFormat(forBus: 0)
        inputNode.installTap(onBus: 0, bufferSize: 1024, format: fmt) { [weak self] buf, _ in
            self?.request?.append(buf)
        }
        try audioEngine.start()
        isListening = true
    }

    func stopListening() {
        audioEngine.stop()
        audioEngine.inputNode.removeTap(onBus: 0)
        recognitionTask?.cancel()
        isListening = false
    }
}
TextToSpeechService.swift — AVSpeechSynthesizerplatform service
import AVFoundation

@Observable
final class TextToSpeechService: NSObject, AVSpeechSynthesizerDelegate {
    private let synthesizer = AVSpeechSynthesizer()
    var isSpeaking = false

    override init() {
        super.init()
        synthesizer.delegate = self
    }

    func speak(
        _ text: String,
        rate:   Float = AVSpeechUtteranceDefaultSpeechRate,
        voice:  AVSpeechSynthesisVoice? = .init(language: "en-US")
    ) {
        stop()
        let utterance     = AVSpeechUtterance(string: text)
        utterance.rate    = rate
        utterance.voice   = voice
        utterance.pitchMultiplier = 1.0
        synthesizer.speak(utterance)
    }

    func stop() {
        synthesizer.stopSpeaking(at: .immediate)
    }

    // AVSpeechSynthesizerDelegate
    func speechSynthesizer(_ s: AVSpeechSynthesizer,
                            didStart u: AVSpeechUtterance) {
        isSpeaking = true
    }
    func speechSynthesizer(_ s: AVSpeechSynthesizer,
                            didFinish u: AVSpeechUtterance) {
        isSpeaking = false
    }
}
WebSocketService.swift — Network.frameworkrealtime
import Network
import Foundation

@Observable
@MainActor
final class WebSocketService {
    private var connection: NWConnection?
    private var retryCount  = 0
    private let maxRetries  = 5

    var messages: [ChatMessage] = []
    var isConnected = false

    func connect(url: URL, token: String) {
        let params   = NWParameters.tls
        let wsOpts   = NWProtocolWebSocket.Options()
        wsOpts.setAdditionalHeaders([("Authorization", "Bearer \(token)")])
        params.defaultProtocolStack.applicationProtocols.insert(
            NWProtocolWebSocket.Options(), at: 0
        )

        connection = NWConnection(
            to: .url(url), using: params
        )
        connection?.stateUpdateHandler = { [weak self] state in
            switch state {
            case .ready:
                self?.isConnected = true
                self?.retryCount  = 0
                self?.receiveLoop()
            case .failed, .cancelled:
                self?.isConnected = false
                self?.scheduleReconnect(url: url, token: token)
            default: break
            }
        }
        connection?.start(queue: .main)
    }

    func send(_ message: ChatMessage) {
        guard let data = try? JSONEncoder().encode(message) else { return }
        let metadata = NWProtocolWebSocket.Metadata(opcode: .text)
        let ctx      = NWConnection.ContentContext(identifier: "msg", metadata: [metadata])
        connection?.send(content: data, contentContext: ctx, completion: .idempotent)
    }

    private func receiveLoop() {
        connection?.receiveMessage { [weak self] data, ctx, _, err in
            if let data, let msg = try? JSONDecoder().decode(ChatMessage.self, from: data) {
                self?.messages.append(msg)
            }
            self?.receiveLoop()   // recursive receive
        }
    }

    private func scheduleReconnect(url: URL, token: String) {
        guard retryCount < maxRetries else { return }
        let delay = TimeInterval(pow(2.0, Double(retryCount)))
        retryCount += 1
        Task { try? await Task.sleep(for: .seconds(delay)); connect(url: url, token: token) }
    }
}
Shared element — matchedGeometryEffectanimation
// Source: product list card
struct ProductCard: View {
    let product:   Product
    var namespace: Namespace.ID
    @Binding var selectedId: String?

    var body: some View {
        VStack {
            AsyncImage(url: product.imageURL)
                .matchedGeometryEffect(
                    id: "image-\(product.id)",
                    in: namespace
                )
                .frame(height: 180)
                .clipShape(.rect(cornerRadius: 12))

            Text(product.name)
                .matchedGeometryEffect(
                    id: "title-\(product.id)",
                    in: namespace
                )
        }
        .onTapGesture {
            withAnimation(.spring(duration: 0.45)) {
                selectedId = product.id
            }
        }
    }
}

// Destination: detail hero
struct ProductDetailView: View {
    let product:   Product
    var namespace: Namespace.ID

    var body: some View {
        ScrollView {
            AsyncImage(url: product.imageURL)
                .matchedGeometryEffect(
                    id: "image-\(product.id)",
                    in: namespace       // same namespace = morph!
                )
                .frame(maxWidth: .infinity, minHeight: 320)
                .ignoresSafeArea(edges: .top)

            Text(product.name)
                .matchedGeometryEffect(
                    id: "title-\(product.id)",
                    in: namespace
                )
                .font(.title.bold())
                .padding()
        }
    }
}
Stagger + phase animationsanimation
// Staggered list entry — PhaseAnimator
struct AnimatedListRow: View {
    let item:  some Identifiable
    let index: Int

    var body: some View {
        ItemCell(item: item)
            .offset(y: 0)
            .opacity(1)
            .transition(
                .asymmetric(
                    insertion: .move(edge: .bottom)
                        .combined(with: .opacity),
                    removal: .opacity
                )
            )
            .animation(
                .spring(duration: 0.4)
                    .delay(Double(index) * 0.06),
                value: item.id
            )
    }
}

// Lottie via SPM (Airbnb/lottie-spm)
import Lottie
struct LottieView: UIViewRepresentable {
    let name:     String
    let loopMode: LottieLoopMode

    func makeUIView(context: Context) -> LottieAnimationView {
        let view = LottieAnimationView(name: name)
        view.contentMode = .scaleAspectFit
        view.loopMode    = loopMode
        view.play()
        return view
    }
    func updateUIView(_ v: LottieAnimationView, context: Context) {}
}

04 — Data Flows

Every Path Traced

From user tap to server and back — how data moves through every iOS architectural layer.

Happy path — tap to UI update

User tap
ViewModel.login()
LoginUseCase.call()
Repo protocol
URLSession async
Decrypt response

Response → cache → UI

DTO received
DTO.toDomain()
SwiftData.upsert()
ViewModel.user = user
SwiftUI re-renders

Offline path — network unavailable

URLError thrown
catch in Repo impl
SwiftData.fetch()
ViewModel.user = cached
Offline banner shown

Debounced search

TextField onChange
searchTask?.cancel()
Task.sleep(350ms)
Task.checkCancellation()
URLSession request

WebSocket message → UI

NWConnection.receive()
JSON decode
WebSocketService.messages.append()
@Observable auto-update

Token refresh — 401 interceptor

401 response
Keychain.getRefreshToken()
POST /auth/refresh
Keychain.save(newTokens)
retry original request

Speech-to-text → API call

SFSpeechRecognizer
transcript updated
onSearchChanged(text)
debounce 350ms
API search request

05 — Feature Matrix

Every Requirement Mapped

All requirements from the original brief — translated to Apple-idiomatic iOS solutions.

🖼️
SwiftUI + @Observable
@Observable macro (iOS 17+) replaces ObservableObject — zero @Published boilerplate. SwiftUI views re-render only when accessed properties change. @MainActor ensures all UI updates on main thread at compile time.
Swift 5.9+ built-in
🔄
Cancellable API calls
Task-based cancellation — store Task handle, call .cancel() before starting new one. Task.checkCancellation() throws CancellationError which propagates up automatically. URLSession cancels the underlying HTTP request when the Task is cancelled.
Swift Concurrency built-in
💾
Offline-first + SwiftData
Write-through cache: network success → SwiftData upsert. Network failure → serve SwiftData cache. @Model macro generates efficient SQLite-backed persistence. FetchDescriptor for typed queries without NSPredicate strings.
SwiftData (iOS 17+)
🔑
Keychain secure storage
Keychain Services for access tokens, refresh tokens, user ID. kSecAttrAccessibleWhenUnlockedThisDeviceOnly — data never leaves the device, inaccessible when locked. Not backed up to iCloud. Survives app reinstall.
Security.framework built-in
🔐
AES-256-GCM encryption
CryptoKit — Apple's native cryptography framework. AES.GCM.seal() with random Nonce per request. IV transmitted in X-IV header. Hardware-accelerated on all Apple Silicon. No third-party crypto dependencies needed.
CryptoKit built-in
⏱️
Debounced search
Swift Concurrency Task-based debounce — cancel previous Task, sleep 350ms, check cancellation, execute. Zero external dependencies. Cleaner than Combine's debounce operator. CancellationError propagates automatically.
Swift Concurrency built-in
🎙️
Google STT via SFSpeechRecognizer
SFSpeechRecognizer with AVAudioEngine for live transcription. shouldReportPartialResults for real-time feedback. Locale-aware — recognizes 60+ languages. On-device recognition available (iOS 16+). Requires NSMicrophoneUsageDescription + NSSpeechRecognitionUsageDescription.
Speech.framework built-in
🔊
TTS via AVSpeechSynthesizer
AVSpeechSynthesizer with locale-specific AVSpeechSynthesisVoice. Adjustable rate, pitch, volume. AVSpeechSynthesizerDelegate for play/pause/complete callbacks. System voices including enhanced neural voices on iOS 16+.
AVFoundation built-in
WebSocket — Network.framework
NWConnection with WebSocket protocol. TLS with certificate pinning via NWParameters. Custom headers for auth. Exponential backoff reconnect. Recursive receiveMessage() loop. Better than URLSessionWebSocketTask for custom TLS configuration.
Network.framework built-in
Animations + shared elements
matchedGeometryEffect for hero/shared element transitions between views. .animation(.spring()) with stagger delay for list entry. PhaseAnimator for multi-step sequences. .transition(.move + .opacity) for view appearance. Lottie via SPM for motion animations.
SwiftUI + lottie-spm
🔒
SSL Certificate Pinning
URLSessionDelegate.urlSession(_:didReceive:completionHandler:) validates server certificate SHA-256 hash against pinned hashes. Cancels challenge if hash doesn't match. Prevents MITM attacks even on jailbroken devices.
Security.framework built-in
🧪
Testing — Swift Testing + XCTest
Swift Testing (@Test macro) for use case unit tests — inject mock repository conforming to protocol. XCTest for UI tests with ViewInspector. Protocol-based DI enables swapping any dependency with a mock. No network in unit tests.
Swift Testing (iOS 17+)

06 — Swift Package Manager

The SPM Registry

Lean dependencies — Apple's built-in frameworks handle most requirements. External packages only where the platform genuinely falls short.

Apple-first principle: Before adding any SPM dependency, check if Apple's built-in frameworks cover it. CryptoKit (encryption), Network.framework (WebSocket), SwiftData (persistence), AVFoundation (TTS), Speech (STT) — all built-in, zero dependency overhead, and deep OS integration.
PackageVersionPurposeLayer
SwiftUIiOS 17+Declarative UI framework — @Observable, matchedGeometryEffect, NavigationStack, animationsPres
SwiftDataiOS 17+Offline-first persistence — @Model macro, FetchDescriptor typed queries, CloudKit sync optionData
CryptoKitiOS 13+AES-256-GCM encryption, SHA-256, HMAC, P-256 key agreement — hardware-acceleratedSecurity
Security.frameworkbuilt-inKeychain Services API — SecItemAdd, SecItemCopyMatching, kSecAttrAccessibleSecurity
LocalAuthenticationiOS 8+Face ID + Touch ID — LAContext.evaluatePolicy biometric authenticationSecurity
Speech.frameworkiOS 10+SFSpeechRecognizer — on-device + server STT, partial results, 60+ localesPlatform
AVFoundationbuilt-inAVSpeechSynthesizer for TTS, AVAudioEngine for microphone capturePlatform
Network.frameworkiOS 12+NWConnection WebSocket, NWPathMonitor for connectivity, custom TLS parametersPlatform
UserNotificationsiOS 10+Push notifications — APNs registration, notification handling, badge/sound/alertPlatform
OSLogiOS 14+Structured logging — Logger(subsystem:category:), privacy-aware, Instruments integrationInfra
lottie-spm^4.4Airbnb Lottie — motion animations from JSON, LottieAnimationView wrapped in UIViewRepresentablePres
swift-composable-architecture^1.15TCA — Reducer, Store, @Dependency for DI, TestStore for exhaustive testing (alternative to MVVM)Pres
Alamofire^5.10HTTP networking with interceptors, retry, multipart — alternative to raw URLSession if preferredData
swift-log^1.6Apple Swift Log — backend-agnostic logging facade, OSLog backendInfra
REPLACED BY APPLE BUILT-INS: JWT library → Keychain + URLSession · SQLite/CoreData wrapper → SwiftData · OpenSSL/libsodium → CryptoKit · Socket.io → Network.framework NWConnection · Alamofire interceptors → URLSessionDelegate · RxSwift/Combine → Swift Concurrency + @Observable

✦ — AI Scaffold

Generate with AI

Paste into Claude, ChatGPT, Gemini, or any AI agent to scaffold this exact architecture from scratch.

 Architecture Prompt
You are a senior iOS architect. Scaffold a production-ready iOS app using Clean Architecture.

Stack:
- Language: Swift 5.10+
- UI: SwiftUI + UIKit bridging where needed
- Architecture: MVVM-C (Coordinators for navigation)
- Async: Swift Concurrency (async/await, Actor, AsyncStream)
- Networking: URLSession with async/await + Codable
- Persistence: SwiftData + UserDefaults + Keychain (KeychainAccess)
- DI: Factory (Michael Long)
- Image: Kingfisher
- Package Manager: Swift Package Manager
- Testing: XCTest + ViewInspector + XCUITest

Provide:
1. Project structure (Features/, Core/, Infrastructure/, Resources/)
2. Coordinator pattern with SwiftUI NavigationStack
3. Generic NetworkClient with async/await + retry logic
4. SwiftData model + repository pattern
5. MVVM ViewModel with @Observable macro
6. Factory DI container setup
7. Keychain service abstraction
8. XCTest unit test + async test pattern for ViewModel
crafted with by Sam
 Copied to clipboard!