Advanced Swift Patterns & Best Practices
Production Swift patterns with annotated code examples. Actors for thread-safe Core ML, AsyncStream for streaming inference, @MainActor for UI dispatch, and protocol-oriented design for testable AI code.
By Ehsan Azish · 3NSOFTS · March 2026
Most Swift tutorials demonstrate patterns in isolation. Production iOS apps require them in combination — an actor wrapping a Core ML model, consumed via AsyncStream in a @MainActor view model, tested against a protocol mock. This reference covers the five patterns that appear in every production AI-powered iOS app, with real implementation examples.
Actor isolation for shared mutable state
IntermediateThe single most important Swift concurrency pattern for iOS apps. Actors serialize access to mutable state — no locks, no DispatchQueues, no race conditions. Core ML's MLModel is not thread-safe; wrapping it in an actor is the correct solution.
// Define the actor — all mutation happens through it
actor InferenceService {
private let model: MyClassifier
private var isProcessing = false
init() throws {
self.model = try MyClassifier(configuration: .init())
}
func predict(input: MLFeatureProvider) async throws -> MLFeatureProvider {
// Actor serializes access — no concurrent predictions on the same model
isProcessing = true
defer { isProcessing = false }
return try await model.prediction(from: input)
}
}
// Caller on any task/thread — suspension point, not a block
let result = try await inferenceService.predict(input: features)MLModel instances are not thread-safe. Calling prediction() from multiple threads concurrently causes crashes. An actor gives you the same safety guarantee as a serial DispatchQueue with less code and better composability.
AsyncStream for streaming AI output
IntermediateAsyncStream bridges callback-based or imperative code (llama.cpp C callbacks, URLSession data delegates, any token-by-token producer) to Swift's structured concurrency model. The correct pattern for streaming LLM inference and progressive UI updates.
// Bridge a callback-based producer to AsyncStream
func streamInference(prompt: String) -> AsyncStream<String> {
AsyncStream { continuation in
Task {
// Foundation Models streaming example
let session = LanguageModelSession()
let response = session.streamResponse(to: prompt)
for try await token in response {
continuation.yield(token)
}
continuation.finish()
}
}
}
// Consume in SwiftUI via .task
struct ResponseView: View {
@State private var output = ""
let prompt: String
var body: some View {
Text(output)
.task {
for await token in streamInference(prompt: prompt) {
output += token
}
}
}
}AsyncStream decouples the producer (which may use callbacks, Combine, or C FFI) from the consumer (SwiftUI .task modifier). The Swift runtime handles back-pressure and cancellation automatically.
@MainActor for guaranteed UI thread dispatch
BeginnerApplying @MainActor to a class or method guarantees that code runs on the main thread — without DispatchQueue.main.async. The standard pattern for ObservableObject-style view models in Swift 6.
@MainActor
@Observable
class ContentViewModel {
var result: String = ""
var isLoading = false
private let service = InferenceService() // actor — runs off main
func run(input: String) async {
isLoading = true
do {
// await crosses the actor boundary — suspends here, resumes on main
let prediction = try await service.classify(input)
result = prediction.label // on main actor — safe
} catch {
result = "Error: \(error.localizedDescription)"
}
isLoading = false
}
}Without @MainActor, updating @Published or @Observable properties from a background Task triggers a runtime warning (or crash in strict concurrency mode). @MainActor makes the guarantee explicit and compiler-verified.
Structured concurrency for parallel inference
AdvancedTaskGroup runs multiple prediction tasks in parallel and collects results. Useful when scoring multiple candidates simultaneously — e.g., classifying a batch of images without blocking, with automatic cancellation if the parent task is cancelled.
struct BatchPrediction {
let index: Int
let label: String
let confidence: Float
}
func classifyBatch(_ images: [UIImage]) async throws -> [BatchPrediction] {
try await withThrowingTaskGroup(of: BatchPrediction.self) { group in
for (index, image) in images.enumerated() {
group.addTask {
// Each task runs concurrently on cooperative thread pool
let result = try await inferenceService.classify(image: image)
return BatchPrediction(index: index, label: result.label, confidence: result.confidence)
}
}
var predictions: [BatchPrediction] = []
for try await prediction in group {
predictions.append(prediction)
}
return predictions.sorted { $0.index < $1.index }
}
}TaskGroup is the correct pattern for data parallelism in Swift. All child tasks are cancelled if the parent is cancelled, preventing "ghost" inference work after a view disappears.
Protocol-oriented service design
IntermediateExtracting service behaviour into a protocol allows the app to use a real implementation in production and a mock in tests — without dependency injection frameworks. The pattern that makes Core ML code testable.
// Define the capability, not the implementation
protocol ClassificationService {
func classify(text: String) async throws -> ClassificationResult
}
// Production implementation backed by Core ML
actor CoreMLClassifier: ClassificationService {
private let model: SentimentClassifier
init() throws { self.model = try SentimentClassifier() }
func classify(text: String) async throws -> ClassificationResult {
let input = SentimentClassifierInput(text: text)
let output = try await model.prediction(input: input)
return ClassificationResult(label: output.label, confidence: output.confidence)
}
}
// Test double — no model required, no disk reads
struct MockClassifier: ClassificationService {
var stubbedResult: ClassificationResult = .init(label: "positive", confidence: 0.9)
func classify(text: String) async throws -> ClassificationResult { stubbedResult }
}
// ViewModel takes the protocol — works with both
@MainActor @Observable
class FeedViewModel {
let classifier: any ClassificationService // inject either implementation
// ...
}This pattern makes it possible to write fast unit tests for AI-powered view models without loading a Core ML model. Protocol-backed mocks run in milliseconds; Core ML model loading takes 50–500ms.