Episode #614

Modularizing and Adding Persistence with SQLiteData

28 minutes
Published on October 27, 2026

This video is only available to subscribers. Get access to this video and 613 others.

Split an app into local Swift package modules, then add a separate database module built on SQLiteData. Covers the @Table macro and its Draft type compared with SwiftData, plus defaultDatabase, DatabaseMigrator and raw SQL migrations with the #sql macro.

Links

Code

The episode model, declared with the @Table macro:

import Foundation
import SQLiteData

@Table
public struct Episode: Hashable, Identifiable {
    public let id: Int
    // ...remaining non-optional properties
}

Bootstrapping the database with a migration:

import SQLiteData

public func appDatabase() throws -> any DatabaseWriter {
    let database = try SQLiteData.defaultDatabase()

    var migrator = DatabaseMigrator()
    #if DEBUG
    migrator.eraseDatabaseOnSchemaChange = true
    #endif

    migrator.registerMigration("create episodes") { db in
        try #sql(
            """
            CREATE TABLE "episodes" (
                "id" INTEGER PRIMARY KEY
                -- ...remaining columns
            ) STRICT
            """
        )
        .execute(db)
    }

    try migrator.migrate(database)
    return database
}

This episode uses Sqlidata 1.12.1, Xcode 27.0, Swift 6.4.

Okay, in this episode I want to add persistence to our application. Right now we have our episode list view holding on to a list of episodes and these are our API models and we did this explicitly so it would be obvious that that's the way they were intended to be used and this is the wire format for our downloaded data. But this is only being kept in memory so when we quit the app we have to download it again and put the data into this array. Now for persistence we could choose SwiftData if we want. I have a series of episodes, on SwiftData, there are some drawbacks that make me want to choose something else. For this project, I'm going to use SQLite data. It is meant as a more flexible replacement for SwiftData that deals with SQLite directly. It allows for certain things that I'll call out along the way that I like and prefer over SwiftData. Before we do that, however, we're going to have to add a package dependency and start live. And so I just want to start with a light refactoring of our project structure. And to start off, we're going to go into our terminal here, and I'm going to create this folder modules. I already created it. And if we go into modules, we're going to do swift package init with a type of library. And then if I just open this up real quick, we will see that we have sources and tests. So this is going to be module, this is more like NS screencast modules, which would be the name of this package. And then each one of these is going to have a library target and a test target. So the first thing I want to do is just move that API client stuff into an API client target library, which is going to be a target. And And it's going to be here, and then this is going to be API client tests.

So because I've done that change, I need to go in here and I need to rename some of these things. So sources instead of modules here, this is going to be API client.

And inside of there, we can just leave that for now. this is going to be API client tests.

And I will say import API client. And we'll leave that alone. And with that, I believe we should be able to open this now up in Xcode. So if I go back into Xcode, and we right click and we say add package dependencies. And here I'm going to choose a directory that contains the package .swift.

And it says what do we want to link? I say add package. So now we have our NSScreencast modules here with our source and our API client. And now I can start moving this stuff from here downward. So I'm going to just take that and drag it over here. This is going to require, because we're in another module now, it's going to require some of these things to be public.

We can now delete this module's Swift file because we just needed one Swift file in there. So now this API client can be deleted, and we're going to need api v2 over here this is going to end up being public for both of those which means we can now delete this one if we go over to our episode that can be moved over and this now can be a public extension on api v2 and if it's a public extension the types inside it become public. We'll delete this episode here. And then episode page has a few different items that we will move down. Again, I'm going to make the extension public here. These two things will be public, and our cursor will be public.

And then finally, our server configuration is another thing that can move into this module, and I will make that public When we do this, I'm going to change this to a static let because we're going to get a main actor concurrency or a swift concurrency related issue here. So I'm just going to make that a static let because we're not going to change it right now. I can delete this old one. And now we've moved everything from the API over into this module. So, when we go to our episodes list view, this now needs to import API client in order to build. Here it says we have no scheme, and I'm not sure what happened with the schemes here.

We'll recreate our scheme. And if we're in our package dependencies target, we should also be able to build just, say, the API client. tests, then we would want to be able to test that as well. So this is part of a protocol, and this initializer is also part of a protocol. Both of those are going to need to be public. This sample data is part of the preview, and this needs to extend something that is defined and elsewhere, so we'll have to do that.

Static property production base URL is internal, so we actually need to go and make this public as well.

Debug description is only available on iOS 26.4 and newer. We need to make a decision of whether we're going to use 26.4. I think that that's fine. even go iOS 27 only, which our project was before. So our API client module right now in the package file doesn't have any platforms. So let's figure out what supported platforms we want. Maybe we'll do macOS 27 and iOS 27 to address that issue.

episodes.data. So we have some stuff in here that needs to be public.

And then the items inside of this also need to be public.

And then all of the elements in here are going to need to be public so that we can work with them in the application. We have this ID type. This also needs to be public, and I feel like this just belongs in another file. So I'm going to cut this onto the pasteboard, and then I'm just going to right click on here, and I think we can just paste. Yeah. So now we have an ID type. This will import foundation.

Okay, so our API client target is building. Let's look at our main app target. Here we will need to import API client. And we may need... No, our initializer should work there. And we need a few more things

And now we're building again. So that refactoring moved some of the stuff out of here and into a Swift package. I could have done this with targets in Xcode, but I prefer something that's a little bit more explicit and defined. I could also use Twist in this case, but I think packages are probably where I would start and then move to Twist as I want to continue to modularize and take advantage of some of twist features. Okay, so now that we've split that up, now it should be fairly easy for us to create another library. And this one I'm going to call database.

So we will copy these two targets.

We will call this one database and this one database tests.

And then we will need a new folder here for database. And I will need some empty Swift file here. So let's just call this database.swift for now. And we're going to make sure that the database doesn't depend on the API client. So these two things will be separate. Over in tests, we're going to create a new folder. Database tests.

Okay. All right. So now we have a package that has more stuff. If I hit save it, you can see that it reloaded it. And now we have the ability to build the database target, which I'm going to switch to now. Okay. So in our database target, we're actually going to be depending on the SQLite data library. So what I'm going to do is go over to the SQLite data project here in GitHub. We can see that the latest release is 1.12.1. And there may just be a copy and paste snippet down here. Yes. So we're going to add this dependencies section and I will depend on a more recent version here. So we'll do 1.21.

products must be first, so we'll move that down, and then we need the comma. So as soon as I hit save here, it parsed this file, and now we need this SQLite data to be a dependency see here. So this will depend on the product named SQLiteData in the package SQLiteData.

And we can see that it was referenced here, but it looks like it hasn't been downloaded yet. Let's start by importing SQLite data. And we'll also import Foundation. And this, we're going to rename this one to Episode. And this is going to be our Episode model. So I'll create a public struct episode.

And this is going to be hashable and identifiable. table, and we're also going to mark this with the table macro. The table macro comes from SQLite data, and it looks like the reason is that I had a typo in the version here, so let's go back and open up Xcode. I may need to tell it to reload.

Now it says resolving. Okay. Seems like an Xcode bug that it should have said that that version doesn't exist. But it resolved the SQLite data as well as its dependencies. Okay. So now if I go into our database target and do a build, it's going to ask me for, and if you've, the first time you've done this build with a new macro, it's going to ask you in Xcode to approve that macro to actually run code because it's running code to write code, essentially. And it's saying that it requires one stored property to be defined, so it has to have something. And we can just say that we have an ID. And if you contrast this with what it would look like in SwiftData. SwiftData would end up having a public class episode, and it would be marked with

the model protocol here for SwiftData. And then we would end up needing a public var ID, which would have to be an optional int. And it would have to be that way even if the database, database, say our local database is going to assign this ID, what does it mean for this to be nil? That means this record is not saved yet. And so you have to have these kind of two representations of what your model objects look like. And for an app, this app doesn't have this, but for an app where you're actually mutating these models and then saving them, once a record gets into the database, it doesn't make sense at all for this to be optional And so what the table macro does is it allows us to do things like I want to create an episode, and in that case I have to specify either the query that decodes these rows or all of the properties that are non-optional. But we also have a draft type, and the draft type lets you take these as optionals. And so that way you can build up the draft type. So an episode.draft, and this might be the one, say, if you had a form where you were filling out stuff, this would be the one that you bind to your UI. And then once it's saved, it becomes a full-featured episode. So this is probably the biggest difference between SQLite data and Swift data, and this is one of the reasons why I've chosen it. Okay, so we do have quite a few other properties. I'm just going to copy and paste some others in here. These, again, they don't really need to be mutable, but the point is all of these are non -optional. And then at some point, we will need to initialize one of these from the API episode that came down from the client. And And because I said I'm not going to depend on the database and the API client are just going to be two distinct things, we're going to stitch this together in our application. So I may decide that this is going to be another module that we reference, but for now I'm going to create a new folder and we'll call this sync. click, and then I'll just create a new empty file, and we'll call this episode plus import.

And this one can import database, and it can also import API client. And now we can have an extension on api.v2.episode, or the opposite. Let's do an extension on episode.draft.

If we did that, then we can say that we want to initialize this with a remote API model. So that's going to be API.v2.episode.

Okay, this is going to need to be, let's see, we're missing one macro here, which we Okay, and the issue is that we actually need to explicitly come over here and say that we depend on database, similar to how when we first added the package dependency to Xcode it asks us which one we wanted to depend on in our application target, so we have to do that here. If we start moving more stuff to the package.swift, it'll be more explicit. But now we get a real error, which self.init is not called, and that's what we want to do, we want to call self.init, and we want to be able to specify all of the different things from this episode. So, okay, we are going to want all of these to be public, and then we also are going to want to generate an initializer for this.

And that initializer will need to be public. Okay, because we don't have this initializer click on episode.draft. Let's switch back over to the application target. We're going to have to do a little bit of a hop here where we can take the episode. The episode is going to be episode here.

And this needs a foundation import.

Okay, so now that we have the episode, now we can say self.init and we can pass in the

and it will create a draft out of that episode. The next thing that we're going to need to do is in our database, we're going to have a way to bootstrap it. I'm going to go over here and create a new empty file called app database.swift. This will import SQLite data. And here we can just have a function app database. And this is going to be throws and then return any database writer. protocol that is used by SQLite data. So we're going to do try SQLite data.default database. This creates one that has a path and a configuration, but both of those are optional. If we take a look at this, we have the source code to this so we can actually see what's going on. This is another benefit to having this over Swift data is you can just go read the code and see So this default database in a live context, simulator or device, a database pool is provisioned in the app container unless it's specifically overridden with the path parameter. In an Xcode preview context, an in-memory database is provisioned. In a test context, a database pool is provisioned in a temporary file. So all of these things are great because it will mean that we're not, say, using a test that has a persistent database that's just going to accumulate data over and over again. Same thing with simulator. these things are going to be isolated, and these defaults seem pretty sensible. If we take a look at the configuration type here, we can see what options we have. Foreign keys enabled. This by default is true. Sometimes you don't want this if you need to do something with syncing, where maybe the foreign reference isn't going to be there yet when you're in the middle of a sync. So there are things like that, read -only databases, which in our case, we might want something like that to have the application have a read-only version of this episodes list, but the importer has a writable version. And there's some other things in here you can take a look at. Okay, so we have this default database. It has nothing in it. It doesn't know about our tables, and it doesn't know, you know, it doesn't have a schema. and this is a big difference between SQLite data and Swift data. Swift data will automatically infer the schema and will automatically try to manage and migrate that data as the data changes but I feel pretty strongly that as these applications evolve and you accumulate data that exists on users devices I don't want to haphazardly rename a property and not have a plan for how to migrate that. And SQLite data makes this very explicit. Swift data makes it so implicit it can introduce situations where it's kind of hard to understand what's going on. So we're going to end up writing raw SQL for this database migration. And we're We're going to have a migrator, which is our database migrator. And then if we are in debug, we're going to add a setting on here called erase database on schema change. So what this is going to mean is as we're iterating with our application, say I have a table with like five columns and then later I want to add 20 columns or I want to refactor or rename it. erase the database any time the schema changes to avoid us having to deal with migrations mid-development. Once you have published a version, that version should be supported indefinitely, and you need migrations to carry you from v0 to v1, v1 to v2, etc., all the way up to the latest version.

So with this little setting here, it's going to make our lives in debug much, much nicer. Then we're going to say migrator.registerMigration. Here we need an identifier. Here I'm going to say create episodes. And then our migrate closure takes a database and can throw. And here I want to say that we're going to create create some SQL, and there is a SQL macro, and then we can give it our three quotes here.

Okay, so here we're going to say create table episodes, and we're going to say strict. And then I'm going to paste in the columns here that we are going to use. And I think this This particular aspect of managing the SQL manually like this is going to turn a lot of people off. However, I think that this is really flexible. It allows you to work with and know exactly what's going on with your database. And then as you migrate, it's going to be pretty obvious how to go, say, from episode number to episode number. something and preserve the integrity of the data. Or as you add new foreign keys or new relationships and things like that, it just becomes a lot easier to manage. Okay. So that gives us this bit of SQL that we then need to execute in that database.

Okay. And on this SQL macro, this is part of Swift Structured Queries, and it probably deserves its own episode entirely, but it tries to defect invalid and partial query fragments and things like that. So if I were to maybe do this, it can tell me, hey, you're missing a closing parentheses there. Okay. So we now have our database migrated. Well, we need to tell it to run our migration. So we say migrator.migrate. We pass in our database.

Let's see. What are we missing now? Oh, and then I need to return the database.

Okay. And these migrations can go in a separate file. You know, we could have a separate file for migrations and just jump into there to do each one. But for now, I think it makes sense just to have it as one thing here. Okay. And then the last thing we're going to do is go into our application. So we've got our regular application here, and we're going to import SQLite data. And in the init for this application.

We're going to say prepare dependencies. That gives us an in out reference for our dependencies. And we can just do $0 dot. And we can start adding some of the common dependencies that other parts of our application might need. And so in this case, we're going to say default database equals and we're going to do try bang app database, which is that function that we wrote. And I don't think I made it public, so let's go make it public.

This comes from the database target. Okay. One thing I'm going to do here I'm going to print out the path to this database. We have configuration, we also have path. So I'm going to print out the SQLite database path is this, so that when we run this application we can see the directory that this is contained in.

And we now have our database path here. So let's go jump up to this. I'm going to start copy this so that we could jump into where this is.

There's a couple of spaces in here, I think. Yeah. Okay. So in our application support, we have the SQLiteData.db. You can open this up with any editor you want. This one is called TablePlus. And we can see that we have an episodes table that has all of this data in it, or all the schema in it, and the GRDB migrations table, which has run that create episodes migration. So it knows whether it needs to run it or not, if that identifier exists in the database. So you need to treat that as frozen and never mutate it. Otherwise, it will try to run that migration again. Okay, so that sort of wraps up how to get started with our refactoring to split it up into a couple of different modules, as well as setting up our database for the first time. In the next episode, we will actually start importing the data into the database and then tie that to the UI.