SwiftData fits naturally into SwiftUI: a view declares the models it needs, and changes in the model context update the result. That convenience can also encourage a screen to become the query builder, editor, validator, and business-rule engine at once.

A more durable split is simple. Let @Query describe which persisted data a view reads. Let ModelContext perform persistence operations. Keep rules that can be expressed without a database in ordinary Swift code that can be tested without launching a container.

Begin with a focused model

import SwiftData

@Model
final class Book {
    var title: String
    var author: String
    var isFinished: Bool
    var updatedAt: Date

    init(title: String, author: String) {
        self.title = title
        self.author = author
        self.isFinished = false
        self.updatedAt = .now
    }
}

The persisted model contains data that must be queried and restored. A rule such as “the normalized title cannot be empty” can live in an input value or creation command. Keeping it outside the view prevents several screens from implementing slightly different validation.

Avoid adding computed presentation state to the persisted model merely because every screen can reach it. Date formatting, localized labels, and temporary selection usually belong closer to the presentation layer.

Keep fixed queries declarative

A screen with a stable filter and sort order can declare them directly:

struct FinishedBooksView: View {
    @Query(
        filter: #Predicate<Book> { $0.isFinished },
        sort: \Book.updatedAt,
        order: .reverse
    )
    private var books: [Book]

    var body: some View {
        List(books) { book in
            Text(book.title)
        }
    }
}

The persistence layer performs the filtering. Fetching every record and calling filter from body couples data volume and update frequency to view evaluation. An in-memory filter is reasonable for a small, already-loaded presentation subset, but it should be an explicit decision rather than the default.

Compose dynamic conditions at initialization

When a parent supplies search text, initialize the query with that value. There is no need to maintain a second result array as a competing source of truth.

struct BooksView: View {
    @Query private var books: [Book]

    init(searchText: String) {
        let term = searchText.trimmingCharacters(in: .whitespaces)
        _books = Query(
            filter: #Predicate<Book> { book in
                term.isEmpty || book.title.contains(term)
            },
            sort: \Book.updatedAt,
            order: .reverse
        )
    }

    var body: some View {
        List(books) { book in
            Text(book.title)
        }
    }
}

For a large store, rebuilding a query after every keystroke may be unnecessary. Debounce at the input boundary so the child receives a stable term. Do not launch several unstructured tasks that race to replace an array; the query condition should remain a single, inspectable value.

Predicate support is intentionally more constrained than arbitrary Swift code. If a desired comparison cannot be represented, normalize a searchable field when writing the model or fetch a bounded candidate set and perform the final presentation filter in memory. Measure the candidate size before choosing the second option.

Make writes explicit

Obtain modelContext from the environment and mutate it from clear user actions: a button, swipe action, or command handler. Validate creation input before inserting a model. For complex batch changes, use a dedicated service and let the caller decide how errors appear in the UI.

Deletion deserves particular care. A view should not continue to treat a deleted model reference as valid navigation state. Capture a stable identifier for routing, dismiss or repair the route after deletion, and allow the query to update the list.

Test rules and persistence separately

Pure rule tests should not start SwiftData. They can verify title normalization, allowed state transitions, and command validation with ordinary values. Persistence integration tests can create an in-memory ModelContainer, insert a small fixture, and verify sorting, predicates, relationships, and deletion.

This division keeps most tests fast without pretending generated persistence behavior is beyond testing. It also makes failures more informative: a domain-rule failure is different from a schema or context failure.

Measure large collections explicitly

A lazy SwiftUI list controls view creation; it does not prove that the persistence layer fetched only visible records. When a collection is large, define fetch limits or business cursor boundaries and measure with representative stores. Search, sort, and pagination must form a stable order, or inserting a record can produce duplicates and gaps between pages.

Relationships can also hide work in body. If a row only needs an author name and a book count, map those values at a deliberate boundary and pass an immutable presentation value to the row. Letting every child wander through a large object graph during scrolling makes performance difficult to predict and makes the row depend on much more than it displays.

Treat schema migration as its own release concern. Adding a required field, changing a relationship, or introducing a uniqueness rule may affect existing stores even when every query still compiles. Test opening representative old data before shipping the new model. Keep fixtures from supported application versions and verify both migration success and the behavior of the first query after opening.

SwiftData is strongest when it owns persistence and querying, while SwiftUI renders the current result. A narrow layer of ordinary, testable domain logic between them keeps automatic updates helpful instead of magical.