How to Implement the Repository Pattern in SwiftUI
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
-
Protocol as the contract.
BookRepositoryProtocoldeclares four async throwing methods —fetchAll,save,delete, andupdate. 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. -
Existential type injection (
any BookRepositoryProtocol). The ViewModel storesprivate let repository: any BookRepositoryProtocol. Usingany(the existential) instead of a generic type parameter keeps the ViewModel class concrete and@Observable-friendly without requiring type-erased wrappers orAnyRepositoryboilerplate. -
Thin
@Modeladapter.BookModelis the SwiftData persistence class; it exposes anasDomaincomputed property that converts back to the pure SwiftBookstruct. This means your domain model never inherits fromNSManagedObjector carries SwiftData annotations — it stays portable. -
@ObservableViewModel triggers automatic view updates. BecauseBookListViewModelis marked@Observable(iOS 17+), any change tobooks,isLoading, orerrorMessageautomatically invalidates the view body — no@PublishedorObservableObjectconformance needed. -
Two
#Previewmacros for instant iteration. One preview injects a pre-populated mock; the other injects an empty store. Because the view acceptsany BookRepositoryProtocol, noModelContainerboilerplate 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
-
iOS 16 has no
@Observable. If you need to support iOS 16 you must fall back toObservableObject+@Published. Use#if swift(>=5.9)or a deployment target guard — but with iOS 17 now as the recommended minimum for new apps, this is rarely worth the complexity. -
Do not call
ModelContextfrom a background thread. SwiftData'sModelContextis notSendable. Always confine yourSwiftDataBookRepositoryto@MainActoror create a dedicated background context viaModelContainer.mainContext. Forgetting this produces confusing runtime crashes rather than compile-time errors. -
Existential boxing can hide
Sendableviolations. Storingany BookRepositoryProtocolsuppresses some Swift concurrency warnings. Add: Sendableto your protocol and mark concrete implementations with@unchecked Sendable(and a comment explaining why) rather than silencing the warning silently vianonisolated(unsafe). -
Don't leak SwiftData types into the domain model. Marking your plain
Bookstruct with@Modelties it to SwiftData's macro machinery, making unit tests require a liveModelContainer. Keep the persistence model (BookModel) and the domain model (Book) separate with a simple mapping layer.
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.