Module 3 · Lesson 2 intermediate

Building the Data Layer

Mario 15 min read

We have our plan. We have our CLAUDE.md. Now we start building — and we start with the foundation.

The data layer is the single most important thing to get right in any app. If your models are wrong, every view built on top of them will be wrong. If your persistence logic has bugs, every feature that reads or writes data will have bugs. This is why we build it first and get it solid before touching any UI.

In this lesson, we are going to use Claude Code to generate our SwiftData models, set up relationships, and create all the CRUD operations. I will show you the exact prompts I use, the code that gets generated, and the few things I always check by hand.

Creating the Models

The Priority Enum

Let us start small. We need a Priority enum before we can build the TodoItem model. Open Claude Code in your project directory and send this:

Create a Priority enum in Models/Priority.swift for our Todo app.
It should have three cases: low, medium, high. Each case needs:
- A display name (String) for the UI
- A color (SwiftUI Color) — green for low, orange for medium,
  red for high
- An SF Symbol name — arrow.down for low, equal for medium,
  arrow.up for high
- Conform to String, Codable, CaseIterable, Comparable
- The raw values should be "low", "medium", "high"
Make it sortable so high priority sorts first.

Here is what Claude Code generates:

import SwiftUI

enum Priority: String, Codable, CaseIterable, Comparable {
    case low
    case medium
    case high

    var displayName: String {
        switch self {
        case .low: "Low"
        case .medium: "Medium"
        case .high: "High"
        }
    }

    var color: Color {
        switch self {
        case .low: .green
        case .medium: .orange
        case .high: .red
        }
    }

    var iconName: String {
        switch self {
        case .low: "arrow.down"
        case .medium: "equal"
        case .high: "arrow.up"
        }
    }

    private var sortOrder: Int {
        switch self {
        case .high: 0
        case .medium: 1
        case .low: 2
        }
    }

    static func < (lhs: Priority, rhs: Priority) -> Bool {
        lhs.sortOrder < rhs.sortOrder
    }
}

Clean. Exactly what we asked for. A few things to note:

  • It used the modern Swift switch syntax (no return keyword needed for single expressions)
  • The Comparable conformance uses a private sortOrder so high priority sorts first — this is the correct approach
  • It conforms to Codable automatically via the String raw value
  • CaseIterable gives us Priority.allCases for free, which we will use in pickers later

This is a case where AI nails it on the first try. Simple, well-defined types with clear requirements are AI’s sweet spot.

The Category Model

Next, the Category model. Here is the prompt:

Create a Category model in Models/Category.swift using SwiftData.
Properties:
- name: String
- systemIcon: String (SF Symbol name)
- It should have a relationship to TodoItem (one category has
  many todos)

Also create a static property with predefined categories:
- Work (briefcase.fill)
- Personal (person.fill)
- Shopping (cart.fill)
- Health (heart.fill)
- Other (tray.fill)

Use the @Model macro. Make it conform to Identifiable.
import Foundation
import SwiftData

@Model
final class Category {
    var name: String
    var systemIcon: String

    @Relationship(deleteRule: .nullify, inverse: \TodoItem.category)
    var todos: [TodoItem]?

    init(name: String, systemIcon: String) {
        self.name = name
        self.systemIcon = systemIcon
    }

    static let predefined: [(name: String, icon: String)] = [
        ("Work", "briefcase.fill"),
        ("Personal", "person.fill"),
        ("Shopping", "cart.fill"),
        ("Health", "heart.fill"),
        ("Other", "tray.fill")
    ]
}

Let me walk through the important decisions here.

The @Relationship attribute. This is the SwiftData way of defining a one-to-many relationship. The deleteRule: .nullify means that if a category is deleted, the todos that belonged to it will have their category set to nil — they will not be deleted. This is the right choice for a Todo app. You do not want deleting a category to wipe out someone’s todos.

The inverse parameter. This tells SwiftData that Category.todos is the inverse of TodoItem.category. SwiftData needs this to maintain referential integrity. If you forget this, you get subtle bugs where relationships do not update correctly.

The predefined static property. Notice it is a tuple array, not actual Category instances. This is intentional — you cannot create @Model instances statically. You need a ModelContext to insert them. We will seed these into the database at app launch.

One thing I would check here: make sure the inverse keypath \TodoItem.category matches the actual property name on the TodoItem model. If they do not match, you get a runtime crash. This is one of those things where AI is usually right, but you should verify.

The TodoItem Model

Now the main model. This is the heart of the app:

Create the TodoItem model in Models/TodoItem.swift using SwiftData.
Properties:
- title: String (required, non-empty)
- notes: String (default empty string)
- dueDate: Date? (optional)
- priority: Priority (default .medium)
- isCompleted: Bool (default false)
- category: Category? (optional relationship)
- createdAt: Date (set automatically to Date.now in init)

Make it conform to Identifiable. Use the @Model macro.
Include a computed property "isOverdue" that returns true if
the todo is not completed and dueDate is before today.
Include a computed property "isToday" that returns true if
dueDate is today.
import Foundation
import SwiftData

@Model
final class TodoItem {
    var title: String
    var notes: String
    var dueDate: Date?
    var priority: Priority
    var isCompleted: Bool
    var category: Category?
    var createdAt: Date

    init(
        title: String,
        notes: String = "",
        dueDate: Date? = nil,
        priority: Priority = .medium,
        isCompleted: Bool = false,
        category: Category? = nil
    ) {
        self.title = title
        self.notes = notes
        self.dueDate = dueDate
        self.priority = priority
        self.isCompleted = isCompleted
        self.category = category
        self.createdAt = Date.now
    }

    var isOverdue: Bool {
        guard !isCompleted, let dueDate else { return false }
        return dueDate < Calendar.current.startOfDay(for: Date.now)
    }

    var isToday: Bool {
        guard let dueDate else { return false }
        return Calendar.current.isDateInToday(dueDate)
    }
}

Let us verify the critical details:

  1. Default values in the initnotes defaults to empty string, priority defaults to .medium, isCompleted defaults to false. This means creating a todo requires only a title: TodoItem(title: "Buy groceries"). Good API design.

  2. createdAt is set in the init, not as a parameter — this prevents callers from accidentally setting a wrong creation date. It is always Date.now.

  3. isOverdue logic — it checks both !isCompleted and dueDate < startOfDay. The startOfDay part is important. Without it, a todo due at 3:00 PM would show as overdue at 3:01 PM, which feels wrong. Using startOfDay means it only shows overdue the next calendar day.

  4. isToday uses Calendar.current.isDateInToday — this is Apple’s built-in method that correctly handles timezone and calendar differences. AI almost always gets this right because it is a common pattern.

  5. The category relationship — it is optional (Category?), which means todos can exist without a category. This matches our plan.

Setting Up the Model Container

Now we need to configure SwiftData in the app entry point. Here is the prompt:

Update the App struct (TodoAppApp.swift) to:
1. Configure a SwiftData ModelContainer with TodoItem and
   Category models
2. On first launch, seed the predefined categories into the
   database
3. Use a simple flag in UserDefaults to track if seeding
   has been done
import SwiftUI
import SwiftData

@main
struct TodoAppApp: App {
    let modelContainer: ModelContainer

    init() {
        do {
            let schema = Schema([TodoItem.self, Category.self])
            let configuration = ModelConfiguration(isStoredInMemoryOnly: false)
            modelContainer = try ModelContainer(for: schema, configurations: [configuration])
            seedCategoriesIfNeeded()
        } catch {
            fatalError("Failed to create ModelContainer: \(error)")
        }
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(modelContainer)
    }

    private func seedCategoriesIfNeeded() {
        let hasSeeded = UserDefaults.standard.bool(forKey: "hasSeededCategories")
        guard !hasSeeded else { return }

        let context = modelContainer.mainContext
        for item in Category.predefined {
            let category = Category(name: item.name, systemIcon: item.icon)
            context.insert(category)
        }

        do {
            try context.save()
            UserDefaults.standard.set(true, forKey: "hasSeededCategories")
        } catch {
            print("Failed to seed categories: \(error)")
        }
    }
}

A few things worth noting here:

The fatalError in the catch block. Normally I would not recommend fatalError, but for model container initialization, it is the standard pattern. If SwiftData cannot create its database, your app genuinely cannot function. There is nothing graceful to fall back to.

The seeding pattern. We use a simple UserDefaults boolean to track whether categories have been seeded. Is this the most elegant solution? No. Is it reliable and simple? Yes. For a Todo app, this is the right call. Do not over-engineer the seeding mechanism.

The mainContext property. This gives us the main actor context directly from the container. We use it for seeding because this runs during app init, which is on the main thread.

Building CRUD Operations

Now let us create the ViewModel that handles all data operations. This is where the real power of AI-generated code shows up — CRUD operations are tedious to write by hand but AI generates them flawlessly.

Create a TodoListViewModel in ViewModels/TodoListViewModel.swift.
It should be an @Observable class that takes a ModelContext in
its init.

Include these methods:
- addTodo(title:notes:dueDate:priority:category:) — creates and
  inserts a new TodoItem
- toggleCompleted(todo:) — flips the isCompleted boolean
- deleteTodo(todo:) — deletes a todo from the context
- deleteTodos(at:from:) — deletes todos at IndexSet offsets
  from a given array (for swipe-to-delete in a List)
- save() — saves the context, handles errors

Include these computed/fetched properties:
- todayTodos: [TodoItem] — incomplete todos due today, sorted
  by priority
- upcomingTodos: [TodoItem] — incomplete todos with future due
  dates, sorted by due date
- completedTodos: [TodoItem] — todos completed in the last 7 days,
  sorted by most recently completed
- unscheduledTodos: [TodoItem] — incomplete todos with no due date,
  sorted by priority
import Foundation
import SwiftData
import os

@Observable
class TodoListViewModel {
    private let modelContext: ModelContext
    private let logger = Logger(subsystem: "com.todoapp", category: "TodoListViewModel")

    var allTodos: [TodoItem] = []

    init(modelContext: ModelContext) {
        self.modelContext = modelContext
        fetchTodos()
    }

    // MARK: - Fetching

    func fetchTodos() {
        let descriptor = FetchDescriptor<TodoItem>(
            sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
        )
        do {
            allTodos = try modelContext.fetch(descriptor)
        } catch {
            logger.error("Failed to fetch todos: \(error.localizedDescription)")
            allTodos = []
        }
    }

    // MARK: - Computed Sections

    var todayTodos: [TodoItem] {
        allTodos
            .filter { !$0.isCompleted && $0.isToday }
            .sorted { $0.priority > $1.priority }
    }

    var upcomingTodos: [TodoItem] {
        let startOfTomorrow = Calendar.current.date(
            byAdding: .day, value: 1,
            to: Calendar.current.startOfDay(for: Date.now)
        ) ?? Date.now

        return allTodos
            .filter { !$0.isCompleted && ($0.dueDate ?? .distantFuture) >= startOfTomorrow }
            .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
    }

    var completedTodos: [TodoItem] {
        let sevenDaysAgo = Calendar.current.date(
            byAdding: .day, value: -7, to: Date.now
        ) ?? Date.now

        return allTodos
            .filter { $0.isCompleted && $0.createdAt >= sevenDaysAgo }
            .sorted { $0.createdAt > $1.createdAt }
    }

    var unscheduledTodos: [TodoItem] {
        allTodos
            .filter { !$0.isCompleted && $0.dueDate == nil }
            .sorted { $0.priority > $1.priority }
    }

    // MARK: - CRUD Operations

    func addTodo(
        title: String,
        notes: String = "",
        dueDate: Date? = nil,
        priority: Priority = .medium,
        category: Category? = nil
    ) {
        let todo = TodoItem(
            title: title,
            notes: notes,
            dueDate: dueDate,
            priority: priority,
            category: category
        )
        modelContext.insert(todo)
        save()
        fetchTodos()
    }

    func toggleCompleted(_ todo: TodoItem) {
        todo.isCompleted.toggle()
        save()
        fetchTodos()
    }

    func deleteTodo(_ todo: TodoItem) {
        modelContext.delete(todo)
        save()
        fetchTodos()
    }

    func deleteTodos(at offsets: IndexSet, from todos: [TodoItem]) {
        for index in offsets {
            let todo = todos[index]
            modelContext.delete(todo)
        }
        save()
        fetchTodos()
    }

    // MARK: - Persistence

    private func save() {
        do {
            try modelContext.save()
        } catch {
            logger.error("Failed to save context: \(error.localizedDescription)")
        }
    }
}

This is solid. Let me point out the decisions that matter:

Fetch then filter approach. The ViewModel fetches all todos once and computes the sections in memory. For a Todo app, this is fine — you will never have enough todos for this to be a performance problem. For an app with thousands of records, you would want separate FetchDescriptor queries with predicates. Know when to optimize and when not to.

fetchTodos() is called after every mutation. After adding, toggling, or deleting, we re-fetch to update the allTodos array. This ensures the computed properties always reflect the current state. Because TodoListViewModel is @Observable, any view watching these properties will update automatically.

The Logger instead of print. Our CLAUDE.md specified os.Logger, and the AI followed the rule. This is exactly why CLAUDE.md matters — without it, you would get print() statements scattered everywhere.

deleteTodos(at:from:) for swipe-to-delete. This method takes an IndexSet and the specific array the offsets refer to. This is important because the offsets are relative to the filtered array (e.g., todayTodos), not the full allTodos array. Getting this wrong is a common bug — the AI handled it correctly.

Testing the Data Layer

Before we move to views, we should verify the data layer works. Here is a quick way to test:

Create a simple test view that I can use temporarily to verify
the data layer works. It should:
- Show a button to add a sample todo
- Show the count of all todos
- Show a list of all todo titles
- Show a button to toggle the first todo's completion
- Show a button to delete the last todo

This is just for testing — we will delete it later.

The AI will generate a temporary view. Use it. Tap the buttons. Verify that todos are created, the count updates, completion toggles work, and deletion works. Check that quitting and relaunching the app preserves the data.

This takes two minutes and can save you an hour of debugging later when a subtle data issue manifests as a broken UI.

Common SwiftData Pitfalls

Let me share the SwiftData issues I see most often when working with AI:

1. Missing inverse relationships. If you define a relationship on one side but forget the inverse: parameter, SwiftData can behave unpredictably. Always specify both sides of a relationship. Check this in your generated code.

2. Enums that are not Codable. SwiftData stores model properties. If you use an enum as a property (like our Priority), it must be Codable. If the AI forgets this, you get a runtime crash — not a compiler error.

3. Default values for new properties. If you add a property to an existing model after you already have data in the database, SwiftData needs a default value for that property. Otherwise, migrating the existing data fails. The AI sometimes forgets this when you ask it to add a property to an existing model.

4. Computed properties and SwiftData. Computed properties like isOverdue and isToday are not stored in the database — they are calculated on the fly. This is correct behavior, but it means you cannot use them in #Predicate queries. If you need to filter by these values in a database query, you need stored properties instead.

Be aware of these. When AI generates SwiftData code, these are the four things I always check manually.

Closing

We now have a complete, functional data layer:

  • Priority enum with display names, colors, icons, and sorting
  • Category model with predefined categories and a relationship to todos
  • TodoItem model with all properties, computed states, and proper defaults
  • Model container configured with seeding for first launch
  • TodoListViewModel with full CRUD operations and section-based filtering

This is the foundation everything else builds on. And we built it in maybe 15 minutes of actual prompting time.

In the next lesson, we build the views. This is where the app comes to life — and where AI really shines, because generating SwiftUI views from descriptions is one of the things it does best.


Key Takeaways

  1. Build the data layer first — models and persistence are the foundation. Get them right before touching UI
  2. Start with the smallest type — build the Priority enum before the models that depend on it
  3. Be explicit about relationships — always specify deleteRule and inverse for SwiftData relationships
  4. CRUD in the ViewModel — keep data operations in @Observable ViewModels, not in views
  5. Test before building UI — a temporary test view takes 2 minutes and catches data bugs early
  6. Check the four SwiftData pitfalls — missing inverses, non-Codable enums, missing defaults, computed properties in predicates

Homework

Build and verify (25 minutes):

  1. Follow along — generate each model using the prompts from this lesson
  2. Build the temporary test view and verify all CRUD operations work
  3. Quit and relaunch the app — verify data persists
  4. Try adding a todo with each priority level. Do the colors and icons look right?
  5. Try deleting a category’s todos and then the category. Does the nullify delete rule work correctly?

Stretch goal: Add a completedAt: Date? property to TodoItem that records when a todo was marked complete. Update toggleCompleted to set this timestamp. Then update the completedTodos filter to use completedAt instead of createdAt for the 7-day window. This is a more accurate design — try prompting Claude Code to make this change and see how it handles modifying existing code.

M

Mario

Founder & CEO

Founder of NativeFirst. Building native Apple apps with SwiftUI and a passion for great user experiences.

Comments

Leave a comment

0/1000