Stop Writing the Same SwiftData Code Twice

Stop Writing the Same SwiftData Code Twice

There's a pattern I kept writing. Fetch, insert, save. Fetch, insert, save. Sometimes
with a predicate, sometimes with a sort descriptor, but always the same shape. I'd
finish a model, open a new feature file, and before I'd written a single line of
business logic I'd already typed try modelContext.insert(thing) from memory.

You can put it in helper methods on the model, or in a repository per type — both are
perfectly reasonable ways to structure it. But the code is still the same code. You're
still writing the same fetch-upsert-delete skeleton for every model, just in a tidier
place. The repetition doesn't go away, it just gets organized.


The Boilerplate Problem

After shipping @LiveQuery (covered in the previous post), the persistence reads
felt clean. Queries lived in models, not views. Dependencies were injectable. Tests used
in-memory containers. The reading side of SwiftData had shape.

But writes were a different story. Every @Model type had its own little set of
persistence methods — some on the model itself, some on a dedicated service object.
Structured, yes. But the same six operations, written again and again for every type.

That's when I started wondering if macros could fix it.


What I Actually Wanted

Before writing anything, I spent some time writing down what the ideal API would look
like if I could just declare it.

@Model
@CRUD
final class Person {
    var name: String
    var age: Int
}

And then use it like this, anywhere in the app:

let adults = try Person.fetch(
    predicate: #Predicate { $0.age >= 18 },
    sort: [SortDescriptor(\.name)]
)

let person = try Person.upsert(Person(name: "Alice", age: 30))
try person.delete()

No service objects. No repeated boilerplate. The model knows how to persist itself, and
the context comes from the same dependency infrastructure already powering @LiveQuery.

Writing it down first helped. I wasn't designing the implementation yet — I was
designing the feeling of using it.


Swift Macros Were the Right Tool

I like macros. The ability to generate code at compile time, with full type safety and
real diagnostics, is one of the more powerful things Swift has added in recent years.
There's something satisfying about a macro that fails loudly when misused rather than
silently doing the wrong thing at runtime. And with agentic coding tools in the loop,
sketching out expansions, iterating on SwiftSyntax node traversal, and wiring up test
snapshots is faster than it's ever been.

The interesting design question wasn't the syntax — it was context. Every generated
method needs a ModelContext, but you don't always want to pass one explicitly. The
generated code has to fit naturally into apps that already use @LiveQuery without
adding a new configuration burden.

The answer was to reuse the same dependency resolution already powering live queries.
Generated methods accept an optional ModelContext and fall back to the app-wide
dependency when it's omitted.

Under the hood @CRUD generates fetch, fetchOne, upsert, upsertCollection,
deleteCollection, and an instance delete — so once the macro is attached, the model
itself becomes the entry point for all persistence operations. The fetch side ends up
looking like this:

static func fetch(
    predicate: Predicate<Person>? = nil,
    sort: [SortDescriptor<Person>] = [],
    modelContext: ModelContext? = nil
) throws -> [Person] {
    let context = try SwiftDataHelpersModelContextResolver.resolve(existing: modelContext)
    var descriptor = FetchDescriptor<Person>(sortBy: sort)
    descriptor.predicate = predicate
    return try context.fetch(descriptor)
}

That fallback — SwiftDataHelpersModelContextResolver — is the same dependency that
@LiveQuery already uses. It means the macro-generated methods fit seamlessly into the
same app setup. No new configuration. If your reads already work, your writes will too.


The Relationship Problem

Once @CRUD existed, a second problem surfaced. Relationship queries.

Consider a Person with a pets: [Pet] relationship. You want to query that person's
pets with filtering and sorting pushed down to the database level — not load everything
into memory and sort there. The manual version involves constructing a FetchDescriptor
with a predicate that matches on persistentModelID. It works, but it's the exact same
code for every collection relationship on every model, with only the type names swapped:

// Filtering & Sorting Relationships without @RelationshipQueries macro

func fetchPersonWithSortedPets(
    id: UUID,
    sortPets: [SortDescriptor<Pet>],
    petsFilter: Predicate<Pet>? = nil,
    modelContext: ModelContext
) throws -> Person {
    let predicate = #Predicate<Person> { $0.id == id }
    let descriptor = FetchDescriptor<Person>(predicate: predicate)
    let person = try modelContext.fetch(descriptor).first

    guard let person else {
        throw ModelError.notFound("Person with id: \(id) not found")
    }

    let owner = #Predicate<Pet> { $0.owner?.id == id }
    let petsPredicate: Predicate<Pet>

    if let petsFilter {
        petsPredicate = #Predicate<Pet> {
            owner.evaluate($0) && petsFilter.evaluate($0)
        }
    } else {
        petsPredicate = #Predicate<Pet> {
            owner.evaluate($0)
        }
    }

    let petsDescriptor = FetchDescriptor<Pet>(predicate: petsPredicate, sortBy: sortPets)
    let pets = try modelContext.fetch(petsDescriptor)

    return .init(
        id: id,
        name: person.name,
        age: person.age,
        pets: pets
    )
}

// Usage
let person: Person = // ...
let pets = try fetchPersonWithSortedPets(
    id: person.id,
    sortPets: [SortDescriptor(\.name)],
    petsFilter: #Predicate<Pet> { !$0.name.isEmpty },
    modelContext: modelContext
)

@RelationshipQueries handles this. Attach it to a @Model type and it scans for
@Relationship(inverse:) collection properties, generating a query method for each:

@Model
@CRUD
@RelationshipQueries
final class Person {
    var name: String
    @Relationship(deleteRule: .cascade, inverse: \Pet.owner)
    var pets: [Pet]
}

// In a view or model:
let sortedPets = person.pets(sort: [SortDescriptor(\.name)])
let filtered = person.pets(
    filter: #Predicate { $0.species == "cat" },
    sort: [SortDescriptor(\.name)]
)

The macro requires inverse: to be explicit — relationships without it are silently
skipped. This was a deliberate choice. Without a known inverse, we can't construct a
correct ownership predicate, and silently fetching everything would be worse than
fetching nothing. Better to require the annotation and be correct than to be convenient
and wrong.


Compile-Time Diagnostics Are Half the Feature

One thing I wanted to get right from the start: if you misuse the macros, you should
get a clear error at the call site, not a crash at runtime.

Attaching @CRUD to an enum, or to a class that isn't marked @Model, produces a
diagnostic:

@CRUD can only be attached to SwiftData @Model types.

Similarly, @RelationshipQueries emits errors for malformed inverse: key paths and
non-collection relationship types:

@RelationshipQueries supports collection relationships declared as
[RelatedModel] or Array<RelatedModel>.

Testing these in swift-macro-testing was actually one of the more satisfying parts of
the project. The snapshot format makes it obvious what the macro produces and where
diagnostics fire:

assertMacro {
    """
    @CRUD
    enum Status { case active, inactive }
    """
} diagnostics: {
    """
    @CRUD
    ┬────
    ╰─ 🛑 @CRUD can only be attached to SwiftData @Model types.
    enum Status { case active, inactive }
    """
}

Seeing the error rendered inline like that made it easy to verify we were pointing at
the right node.


What This Doesn't Replace

Macros generate the common case. They're not a general persistence layer.

If you need custom fetch logic — complex multi-predicate queries, aggregations, or
operations that span multiple model types — write those explicitly. The generated methods
cover the 90% that's identical across every model. The remaining 10% should stay
explicit, because that's where your actual domain logic lives.

@CRUD and @RelationshipQueries are available in SwiftDataHelpersMacros, a separate
module within the same package. Just add it alongside SwiftDataHelpers in your target
dependencies and import it where needed — if you only use @LiveQuery, nothing changes.


Wrapping Up

The macros shipped as part of 1.1.0. The package is on GitHub at
vadimkrutovlv/swift-data-helpers. If you're already using @LiveQuery and have
been hand-writing the same fetch-upsert-delete cycle across your models, this should
feel like a small relief.

And if you're not — well, maybe the next post will convince you.

Subscribe to Swiftloop

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe