Skip to main content
3Nsofts logo3Nsofts
Swift Patterns · Code ReferenceSwift 6.0 · iOS 17+

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.

01

Actor isolation for shared mutable state

Intermediate

The 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)
Why:

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.

02

AsyncStream for streaming AI output

Intermediate

AsyncStream 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
                }
            }
    }
}
Why:

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.

03

@MainActor for guaranteed UI thread dispatch

Beginner

Applying @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
    }
}
Why:

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.

04

Structured concurrency for parallel inference

Advanced

TaskGroup 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 }
    }
}
Why:

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.

05

Protocol-oriented service design

Intermediate

Extracting 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
    // ...
}
Why:

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.

Patterns in this guide

Authoritative References

Related guides

More resources