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.
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.
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.
03 — Code Patterns
Swift Blueprints
Production-ready Swift 6. Every pattern is async/await-native, Sendable-safe, and compiles with strict concurrency enabled.
// 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 }
// 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:)
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." } } }
@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) } } }
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) } }
// 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) } }
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: "") } } }
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() } }
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) } }
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() } } }
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() } }
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)) ) } }
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) } }
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) } }
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 } }
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 } }
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) } } }
// 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() } } }
// 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
Response → cache → UI
Offline path — network unavailable
Debounced search
WebSocket message → UI
Token refresh — 401 interceptor
Speech-to-text → API call
05 — Feature Matrix
Every Requirement Mapped
All requirements from the original brief — translated to Apple-idiomatic iOS solutions.
Swift 5.9+ built-in
Swift Concurrency built-in
SwiftData (iOS 17+)
Security.framework built-in
CryptoKit built-in
Swift Concurrency built-in
Speech.framework built-in
AVFoundation built-in
Network.framework built-in
SwiftUI + lottie-spm
Security.framework built-in
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.
| Package | Version | Purpose | Layer |
|---|---|---|---|
SwiftUI | iOS 17+ | Declarative UI framework — @Observable, matchedGeometryEffect, NavigationStack, animations | Pres |
SwiftData | iOS 17+ | Offline-first persistence — @Model macro, FetchDescriptor typed queries, CloudKit sync option | Data |
CryptoKit | iOS 13+ | AES-256-GCM encryption, SHA-256, HMAC, P-256 key agreement — hardware-accelerated | Security |
Security.framework | built-in | Keychain Services API — SecItemAdd, SecItemCopyMatching, kSecAttrAccessible | Security |
LocalAuthentication | iOS 8+ | Face ID + Touch ID — LAContext.evaluatePolicy biometric authentication | Security |
Speech.framework | iOS 10+ | SFSpeechRecognizer — on-device + server STT, partial results, 60+ locales | Platform |
AVFoundation | built-in | AVSpeechSynthesizer for TTS, AVAudioEngine for microphone capture | Platform |
Network.framework | iOS 12+ | NWConnection WebSocket, NWPathMonitor for connectivity, custom TLS parameters | Platform |
UserNotifications | iOS 10+ | Push notifications — APNs registration, notification handling, badge/sound/alert | Platform |
OSLog | iOS 14+ | Structured logging — Logger(subsystem:category:), privacy-aware, Instruments integration | Infra |
lottie-spm | ^4.4 | Airbnb Lottie — motion animations from JSON, LottieAnimationView wrapped in UIViewRepresentable | Pres |
swift-composable-architecture | ^1.15 | TCA — Reducer, Store, @Dependency for DI, TestStore for exhaustive testing (alternative to MVVM) | Pres |
Alamofire | ^5.10 | HTTP networking with interceptors, retry, multipart — alternative to raw URLSession if preferred | Data |
swift-log | ^1.6 | Apple Swift Log — backend-agnostic logging facade, OSLog backend | Infra |
| 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.
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