Skip to Content
Course content

54: Building a Local Persistence Layer

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Up until now, your data has lived and died with the lifecycle of your app. You refresh the simulator, and your state vanishes. In a real-world app, that's a disaster. Whether it's a user's preferences, a cached feed, or a list of saved items, you need a way to make that data survive a reboot. I've seen a lot of developers approach this as a "utility" problem—something you just solve with a quick helper function—but that's a trap that leads to a codebase that's nearly impossible to test or evolve.

The Temptation of the Quick Save

Let's say we're building a simple Reading List app. You have a Book struct, and you want to save the user's list to the disk. The naive approach—the one I see most often in early projects—is to just sprinkle JSONEncoder and FileManager calls directly inside your ViewModel. It looks something like this:

class ReadingListViewModel: ObservableObject {
    @Published var books: [Book] = []

    func addBook(_ book: Book) {
        books.append(book)
        
        // Naive persistence: doing it all right here
        if let data = try? JSONEncoder().encode(books) {
            let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
                .appendingPathComponent("books.json")
            try? data.write(to: url)
        }
    }
}

On the surface, this works. It's fast to write, and for a tiny app, it gets the job done. But we're thinking as engineers, not just coders. The problem here is that your ViewModel is now doing two entirely different jobs: it's managing the UI state and it's managing the file system. It's tightly coupled to the disk. If you decide tomorrow that a JSON file is too slow and you want to move to SwiftData or a SQLite database, you have to hunt down every single place you wrote to a file and rewrite the logic.

Abstracting the Storage Engine

The better way is to build a dedicated persistence layer. I prefer using a Repository pattern. Instead of the ViewModel knowing how the data is saved, it should only know that it can be saved. We define a protocol that describes the capabilities we need, and then we create a concrete implementation that handles the messy details of the file system.

protocol ReadingListStorage {
    func save(_ books: [Book]) throws
    func load() throws -> [Book]
}

class FileReadingListStorage: ReadingListStorage {
    private let fileURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        .appendingPathComponent("books.json")

    func save(_ books: [Book]) throws {
        let data = try JSONEncoder().encode(books)
        try data.write(to: fileURL, options: .atomic)
    }

    func load() throws -> [Book] {
        let data = try Data(contentsOf: fileURL)
        return try JSONDecoder().decode([Book].self, from: data)
    }
}

Now, your ViewModel just takes a ReadingListStorage object through its initializer. It doesn't know if it's talking to a JSON file, a mock object for testing, or a remote server. This is called Dependency Injection, and it's the secret to keeping your Swift code from becoming a "big ball of mud" as the project grows.

Where the Naive approach breaks

You might be thinking, "Why bother with the extra files and protocols for a simple list?" Here is where the naive approach actually fails in production. First, there's the issue of Atomicity. If the app crashes or the battery dies exactly while data.write(to: url) is executing in your ViewModel, you can end up with a corrupted JSON file that prevents the app from ever launching again. In the better approach, I used .atomic, which writes to a temporary file first and then swaps it, ensuring the old save is preserved if the new one fails.

Second, testing becomes a nightmare. If your persistence logic is baked into the ViewModel, you can't write a unit test for your business logic without actually writing files to your Mac's hard drive. That makes tests slow and flaky. With the repository pattern, you can create a MockStorage class that just keeps the books in an array in memory. Your tests run in milliseconds, and they don't leave "junk" files all over your system.

Lastly, consider performance. Writing the entire array to disk every time a single book is added is fine for ten books. It's a disaster for ten thousand. By isolating the persistence layer, you can optimize the save method—perhaps by adding a debounce timer or switching to a database—without touching a single line of code in your UI layer.




📋 Practical Task

Exercise: Implementing a Persistent Bookmark Manager

Build a persistence layer for a Bookmark Manager app. Your goal is to move the storage logic out of the view logic and into a dedicated repository.

  • Create a Bookmark struct that conforms to Codable (include title and url).
  • Define a protocol named BookmarkPersistence with two methods: saveBookmarks(_ bookmarks: [Bookmark]) throws and fetchBookmarks() throws -> [Bookmark].
  • Create a class DiskBookmarkStorage that implements this protocol using JSONEncoder and FileManager. Ensure you use the .atomic write option.
  • Create a BookmarkViewModel that accepts any object conforming to BookmarkPersistence via its initializer.
  • Implement a method in the ViewModel called persistCurrentBookmarks() that calls the storage layer, and a loadSavedBookmarks() method that populates the ViewModel's local array.

Challenge: Create a MockBookmarkStorage class that implements the protocol but stores the bookmarks in a simple local array. Initialize your ViewModel with this mock to verify that your ViewModel logic works independently of the actual disk.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.