Keeping SwiftData behind a boundary
Learn how to keep SwiftData out of SwiftUI views.
13 Jul 2026 · 5 min read
SwiftData integrates naturally with SwiftUI. It lets us build persistence into an app quickly, but it also makes it easy to mix persistence details directly into views.
In this article, we'll look at how we can create a boundary between the UI and the persistence layer when working with SwiftData.

A view can read from a ModelContext, use @Query, create model objects, insert them, delete them, and save the context. For a small prototype this can feel very convenient. In a real app, however, this kind of direct access can become one of the first places where responsibilities start to blur.
The direct version of a SwiftData-backed view often starts like this:
struct ItemListView: View {@Environment(\.modelContext) private var modelContext@Query private var items: [PersistedItem]var body: some View {List(items) { item inText(item.timestamp, format: .dateTime)}.toolbar {Button("Add") {modelContext.insert(PersistedItem(timestamp: Date()))}}}}
This is compact, and SwiftData makes the persistence work almost invisible. The tradeoff is that the view now knows quite a lot about how data is stored. If we later add validation, error handling, sorting rules, sync behavior, test data, or a different persistence strategy, the view might take on too many responsibilities.
To keep responsibilities clean, we can put a small persistence boundary between the UI-facing code and the storage. We can make the persisted type explicit:
@Modelfinal class PersistedItem {var id: UUIDvar timestamp: Dateinit(id: UUID = UUID(), timestamp: Date) {self.id = idself.timestamp = timestamp}}
The UI-facing model can then be a plain value type:
struct Item: Identifiable, Equatable, Sendable {let id: UUIDlet timestamp: Date}
PersistedItem tells us that this type belongs to the storage layer. Item is the value the feature works with. This makes the boundary visible in the code and reduces the chance that a persistence model slowly spreads through views, navigation state, previews, and tests.
The repository protocol can describe the feature's persistence needs without exposing SwiftData:
@MainActorprotocol ItemRepository {func fetchItems() throws -> [Item]func addItem(timestamp: Date) throws}
The view model depends on this boundary instead of depending on SwiftData directly:
@MainActor@Observablefinal class ItemListViewModel {private let repository: any ItemRepositoryprivate(set) var items: [Item] = []init(repository: any ItemRepository) {self.repository = repository}func loadItems() throws {items = try repository.fetchItems()}func addItem() throws {try repository.addItem(timestamp: Date())try loadItems()}}
Now the view can stay focused on presentation and user interaction. It displays viewModel.items and calls methods such as viewModel.loadItems() or viewModel.addItem(), but it no longer knows about ModelContext, fetch descriptors, or SwiftData insert calls.
The SwiftData-specific implementation moves into the repository:
@MainActorfinal class ItemRepositoryImpl: ItemRepository {private let modelContext: ModelContextinit(modelContext: ModelContext) {self.modelContext = modelContext}func fetchItems() throws -> [Item] {let descriptor = FetchDescriptor<PersistedItem>(sortBy: [SortDescriptor(\.timestamp, order: .forward)])return try modelContext.fetch(descriptor).map { persistedItem inItem(id: persistedItem.id, timestamp: persistedItem.timestamp)}}func addItem(timestamp: Date) throws {modelContext.insert(PersistedItem(timestamp: timestamp))try modelContext.save()}}
This stronger boundary comes with a tradeoff: we now have two types and mapping code between them. For very small prototypes, that can feel like extra work. In an app that is meant to grow, however, the advantages are often worth it:
- PersistedItem makes it clear which type belongs to SwiftData.
- Item can be a simple value type that is easier to pass around, compare, preview, and test.
- Item can conform to Sendable, which fits better with Swift concurrency than passing persistence-managed reference objects through the app.
- Migration concerns stay closer to the persistence layer.
- The repository becomes the place where storage details, mapping, fetch descriptors, inserts, and saves are handled.
With a boundary in place, the view model can be tested with an in-memory or fake repository, previews can provide controlled data, and the persistence implementation can evolve in a controlled way.



Newsletter
Like to support my work?
Say hi
Related tags
Articles with related topics
Latest articles and tips



