```html SwiftUI: How to Implement Repository Pattern (iOS 17+, 2026)

How to Implement the Repository Pattern in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: Protocol / Generics Updated: May 11, 2026
TL;DR

Define a generic Repository protocol using Swift Protocols and Generics, then inject concrete or mock implementations into your @Observable ViewModel. This decouples your SwiftUI views from any specific data source — SwiftData, REST, or in-memory — without a single line of change in the view layer.

protocol Repository<Entity> {
    associatedtype Entity
    func fetchAll() async throws -> [Entity]
    func save(_ entity: Entity) async throws
    func delete(_ entity: Entity) async throws
}

struct Book: Identifiable, Codable {
    var id: UUID = .init()
    var title: String
}

final class MockBookRepository: Repository {
    typealias Entity = Book
    private var store: [Book] = []
    func fetchAll() async throws -> [Book] { store }
    func save(_ book: Book) async throws { store.append(book) }
    func delete(_ book: Book) async throws { store.removeAll { $0.id == book.id } }
}

Full implementation

The repository pattern introduces an abstraction layer between your domain model and the data layer. In SwiftUI, the cleanest approach is to define a primary-associated-type protocol (Repository<Entity>), write one concrete implementation (e.g. backed by SwiftData), and one mock for previews and tests. An @Observable ViewModel holds a reference typed to the protocol — not the concrete class — making the entire stack mockable without any conditional compilation tricks.

import SwiftUI
import SwiftData

// MARK: - Domain Model

struct Book: Identifiable, Equatable, Codable {
    var id: UUID = .init()
    var title: String
    var author: String
    var isFavorite: Bool = false
}

// MARK: - Repository Protocol (Primary Associated Type — Swift 5.7+)

protocol BookRepositoryProtocol {
    func fetchAll() async throws -> [Book]
    func save(_ book: Book) async throws
    func delete(_ book: Book) async throws
    func update(_ book: Book) async throws
}

// MARK: - In-Memory / Mock Repository

final class MockBookRepository: BookRepositoryProtocol {
    private var store: [Book]

    init(store: [Book] = [
        Book(title: "The Swift Programming Language", author: "Apple"),
        Book(title: "Point-Free Composable Architecture", author: "Brandon & Stephen")
    ]) {
        self.store = store
    }

    func fetchAll() async throws -> [Book] { store }

    func save(_ book: Book) async throws {
        store.append(book)
    }

    func delete(_ book: Book) async throws {
        store.removeAll { $0.id == book.id }
    }

    func update(_ book: Book) async throws {
        guard let index = store.firstIndex(where: { $0.id == book.id }) else { return }
        store[index] = book
    }
}

// MARK: - SwiftData Repository

@MainActor
final class SwiftDataBookRepository: BookRepositoryProtocol {
    private let context: ModelContext

    init(context: ModelContext) {
        self.context = context
    }

    func fetchAll() async throws -> [Book] {
        let descriptor = FetchDescriptor<BookModel>(sortBy: [SortDescriptor(\.title)])
        return try context.fetch(descriptor).map(\.asDomain)
    }

    func save(_ book: Book) async throws {
        let model = BookModel(book: book)
        context.insert(model)
        try context.save()
    }

    func delete(_ book: Book) async throws {
        let id = book.id
        let descriptor = FetchDescriptor<BookModel>(predicate: #Predicate { $0.id == id })
        if let model = try context.fetch(descriptor).first {
            context.delete(model)
            try context.save()
        }
    }

    func update(_ book: Book) async throws {
        let id = book.id
        let descriptor = FetchDescriptor<BookModel>(predicate: #Predicate { $0.id == id })
        if let model = try context.fetch(descriptor).first {
            model.title = book.title
            model.author = book.author
            model.isFavorite = book.isFavorite
            try context.save()
        }
    }
}

// Thin SwiftData model — keeps domain model clean
@Model final class BookModel {
    var id: UUID
    var title: String
    var author: String
    var isFavorite: Bool

    init(book: Book) {
        self.id = book.id
        self.title = book.title
        self.author = book.author
        self.isFavorite = book.isFavorite
    }

    var asDomain: Book {
        Book(id: id, title: title, author: author, isFavorite: isFavorite)
    }
}

// MARK: - ViewModel (@Observable, iOS 17+)

@Observable
final class BookListViewModel {
    var books: [Book] = []
    var errorMessage: String?
    var isLoading = false

    private let repository: any BookRepositoryProtocol

    init(repository: any BookRepositoryProtocol) {
        self.repository = repository
    }

    func load() async {
        isLoading = true
        defer { isLoading = false }
        do {
            books = try await repository.fetchAll()
        } catch {
            errorMessage = error.localizedDescription
        }
    }

    func add(title: String, author: String) async {
        let book = Book(title: title, author: author)
        do {
            try await repository.save(book)
            await load()
        } catch {
            errorMessage = error.localizedDescription
        }
    }

    func remove(at offsets: IndexSet) async {
        for index in offsets {
            let book = books[index]
            do {
                try await repository.delete(book)
            } catch {
                errorMessage = error.localizedDescription
            }
        }
        await load()
    }
}

// MARK: - View

struct BookListView: View {
    @State private var viewModel: BookListViewModel
    @State private var showingAdd = false

    init(repository: any BookRepositoryProtocol = MockBookRepository()) {
        _viewModel = State(initialValue: BookListViewModel(repository: repository))
    }

    var body: some View {
        NavigationStack {
            Group {
                if viewModel.isLoading {
                    ProgressView()
                } else {
                    List {
                        ForEach(viewModel.books) { book in
                            VStack(alignment: .leading, spacing: 2) {
                                Text(book.title).font(.headline)
                                Text(book.author).font(.subheadline).foregroundStyle(.secondary)
                            }
                            .accessibilityElement(children: .combine)
                            .accessibilityLabel("\(book.title) by \(book.author)")
                        }
                        .onDelete { offsets in
                            Task { await viewModel.remove(at: offsets) }
                        }
                    }
                }
            }
            .navigationTitle("My Library")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("Add", systemImage: "plus") { showingAdd = true }
                        .accessibilityLabel("Add book")
                }
            }
            .sheet(isPresented: $showingAdd) {
                AddBookSheet { title, author in
                    Task { await viewModel.add(title: title, author: author) }
                }
            }
            .alert("Error", isPresented: Binding(
                get: { viewModel.errorMessage != nil },
                set: { if !$0 { viewModel.errorMessage = nil } }
            )) {
                Button("OK", role: .cancel) {}
            } message: {
                Text(viewModel.errorMessage ?? "")
            }
        }
        .task { await viewModel.load() }
    }
}

struct AddBookSheet: View {
    @Environment(\.dismiss) private var dismiss
    @State private var title = ""
    @State private var author = ""
    let onSave: (String, String) -> Void

    var body: some View {
        NavigationStack {
            Form {
                TextField("Title", text: $title)
                TextField("Author", text: $author)
            }
            .navigationTitle("New Book")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") {
                        onSave(title, author)
                        dismiss()
                    }
                    .disabled(title.isEmpty || author.isEmpty)
                }
            }
        }
    }
}

#Preview("Mock Data") {
    BookListView(repository: MockBookRepository())
}

#Preview("Empty State") {
    BookListView(repository: MockBookRepository(store: []))
}

How it works

  1. Protocol as the contract. BookRepositoryProtocol declares four async throwing methods — fetchAll, save, delete, and update. Neither the ViewModel nor the View ever imports SwiftData directly; they only know about this protocol, so swapping backends is a one-line change at the call site.
  2. Existential type injection (any BookRepositoryProtocol). The ViewModel stores private let repository: any BookRepositoryProtocol. Using any (the existential) instead of a generic type parameter keeps the ViewModel class concrete and @Observable-friendly without requiring type-erased wrappers or AnyRepository boilerplate.
  3. Thin @Model adapter. BookModel is the SwiftData persistence class; it exposes an asDomain computed property that converts back to the pure Swift Book struct. This means your domain model never inherits from NSManagedObject or carries SwiftData annotations — it stays portable.
  4. @Observable ViewModel triggers automatic view updates. Because BookListViewModel is marked @Observable (iOS 17+), any change to books, isLoading, or errorMessage automatically invalidates the view body — no @Published or ObservableObject conformance needed.
  5. Two #Preview macros for instant iteration. One preview injects a pre-populated mock; the other injects an empty store. Because the view accepts any BookRepositoryProtocol, no ModelContainer boilerplate is required in previews.

Variants

Generic protocol with primary associated type (Swift 5.7+ any/some syntax)

If you want a fully generic repository you can use in multiple modules, primary associated types let you write some Repository<Book> in function signatures for zero runtime overhead:

// Primary associated type — declared once
protocol Repository<Entity: Identifiable & Sendable> {
    associatedtype Entity
    func fetchAll() async throws -> [Entity]
    func save(_ entity: Entity) async throws
    func delete(id: Entity.ID) async throws
}

// ViewModel using opaque type (avoids existential overhead)
@Observable
final class GenericListViewModel<Repo: Repository>
    where Repo.Entity == Book {

    var items: [Book] = []
    private let repo: Repo

    init(repo: Repo) { self.repo = repo }

    func load() async throws {
        items = try await repo.fetchAll()
    }
}

// Usage — type-inferred, no boxing
let vm = GenericListViewModel(repo: MockBookRepository())

Caching layer via repository composition

Wrap one repository with another to add caching without touching the ViewModel. Create a CachingBookRepository that conforms to BookRepositoryProtocol, holds an inner any BookRepositoryProtocol (the real API), and keeps a local [Book] array as a write-through cache. When fetchAll() is called, return the cache immediately and refresh from the inner repository in the background. Because the ViewModel only knows the protocol, caching is invisible to all callers above it.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a repository pattern in SwiftUI for iOS 17+.
Use Swift Protocols and Generics (Protocol/Generics).
Define a BookRepositoryProtocol with fetchAll, save, delete, update.
Provide a MockBookRepository and a SwiftData-backed implementation.
Inject via `any BookRepositoryProtocol` into an @Observable ViewModel.
Make it accessible (VoiceOver labels on list rows).
Add a #Preview with realistic sample data and one empty-state preview.

In Soarias's Build phase, drop this prompt into the code generation step after your screens are scaffolded — the repository layer slots cleanly between your data models and your ViewModels, so Claude Code can wire them together without touching your UI files.

Related

FAQ

Does the repository pattern work on iOS 16?

Yes, but you must replace @Observable with ObservableObject + @Published, and swap the #Preview macro for a PreviewProvider. The protocol and generic layers themselves are fully compatible with Swift 5.7 / iOS 16. For new projects targeting iOS 17+, use the approach shown above.

Should I use any BookRepositoryProtocol (existential) or a generic type parameter?

Use any (existential) in your ViewModel when you need runtime polymorphism — e.g. swapping between mock and real implementations at startup without recompiling. Use some (opaque) or a generic type parameter in leaf helper functions where the concrete type is always known at compile time, for zero boxing overhead. For most app ViewModels, existential is the right trade-off.

What is the UIKit equivalent?

In UIKit, the repository pattern works identically at the protocol layer. You'd inject the repository into a UIViewController (or a separate service class it holds) and call its async methods inside Task { } blocks triggered by lifecycle events like viewDidLoad. The pattern pre-dates SwiftUI — it's a pure Swift / architecture pattern, not a framework-specific one.

Last reviewed: 2026-05-11 by the Soarias team.

```