1 minute read

Building a To-Do List App with Core Data in Swift

Core Data is one of those Apple frameworks that lives quietly under the hood of thousands of App Store titles, and yet it still sparks debate whenever a junior Swift developer asks which persistence layer to learn first. SwiftData has grabbed headlines since its debut, but Core Data remains the engine that powers many production apps in Australia and elsewhere, and it is well worth mastering before its younger sibling takes over.

If you are sketching your first non-trivial iOS project, a to-do list is the classic proving ground. It exercises the essentials: a managed object model, a context, fetched results bound to a table or collection view, and the small but persistent user expectations that come with saving work between launches. The exercise also scales surprisingly far, which makes it a smart portfolio piece for anyone applying to graduate roles in Sydney, Melbourne or Brisbane.

Why Core Data still earns a place in your toolbox

Critics sometimes describe Core Data as verbose, and they are not wrong when measured against a single-line SwiftData call. What they overlook is the depth of the framework: it ships with faulting, change tracking, undo manager support, and a migration system that has been hardened over more than a decade. Apps handling sensitive data, from medical journals to financial ledgers, often lean on that maturity because the failure modes are well understood and the tooling is mature.

For an Australian developer shipping to the local market, there is another reason to be conservative. The Privacy Act 1988 and the Notifiable Data Breaches scheme expect developers to handle personal information responsibly, and local users have come to expect that their notes, reminders and lists stay on-device by default. Core Data with the default SQLite store keeps data inside the app sandbox by default, which lines up neatly with that expectation. You do not need a server, you do not need an account, and you do not need to worry about cross-border data export questions.

It is also a framework that pays off when you start interviewing. Recruiters in Perth and Adelaide regularly test new graduates on the difference between a managed object context and a persistent container, because that vocabulary appears in real codebases. Even if your future shop uses SwiftUI exclusively, the conceptual model translates without much fuss.

Shaping the model for everyday tasks

Open Xcode, create a new iOS App project, and then add a new file of type Data Model. Name it TaskModel, and inside the editor add two entities. The first is Task with three attributes: a String title, a Date createdAt, and a Boolean isCompleted. The second entity is Category with a String name and a one-to-many relationship to Task. Setting the deletion rule on the Task side to Cascade will keep your database tidy if a user removes an entire category.

A common mistake is leaving Codegen on for every entity and forgetting to switch it off once you want to write your own NSManagedObject subclasses. Head to the right inspector and set Codegen to Manual/None, then create two matching files with @NSManaged properties that mirror the model. Code generation clashes are easy to miss until Xcode throws duplicate symbol errors at build time, so getting this step right once saves a lot of late-night debugging later.

Think carefully about default values and optionality. Title should not be optional, because empty rows confuse users and clutter lists. CreatedAt deserves a default of Date() so that you can rely on it for sorting. Save yourself a few hours by designing the model with a real device in mind: a to-do list that works without a network connection feels especially natural in regional parts of Australia, where mobile signal on a morning train ride from Geelong into Melbourne can be patchy.

The persistence stack in plain Swift

The container is the entry point to Core Data, and you usually attach it to your App struct so it lives for the lifetime of the process. Inside the persistence property, instantiate an NSPersistentContainer with the same name as your data model file. That single line performs the heavy lifting: it loads the model, opens the SQLite store and prepares a viewContext ready for the main thread.

Background work belongs in a private context. Create a helper that calls container.newBackgroundContext() and exposes a save() method. Wrap the heavy lifting inside a perform block so that the framework hops onto the right queue automatically, and only call save() when changes.isEmpty is false. A surprising number of bugs reported on local iOS meetups trace back to forgetting that perform { } is asynchronous, which trips up developers who come from a Swift concurrency background.

A subtle but important point is the merge policy. Set container.viewContext.mergePolicy to NSMergeByPropertyObjectTrumpMergePolicy and turn on automaticallyMergesChangesFromParent on the viewContext. With those two settings, writes from a background task propagate into your list view without you having to reload it manually. Small wins like this are what keep your app feeling responsive on older devices, including the trusty iPad that sits on many Australian kitchen benches.

Driving the list with fetched results

A NSFetchedResultsController is still the cleanest way to feed a UITableView or UICollectionView with live Core Data. Build one with a fetch request on Task, sorted by createdAt descending, and assign its delegate to your view controller. Implement controllerWillChangeContent, didChange anObjectAt at forUpdate forChangeType, and controllerDidChangeContent to translate the diffs into table operations.

In modern Swift code, you will often skip the controller and reach for Combine or SwiftUI with @FetchRequest. Both paths work, but the controller approach is still worth learning because it appears in legacy codebases at agencies in Surry Hills and Southbank. Once you understand the controller, switching to a SwiftUI list later is trivial: the fetch request is identical, only the binding surface changes.

Do not forget the filter. A predicate such as NSPredicate(format: "isCompleted == %@", NSNumber(value: false)) is enough to power the common "Hide completed" toggle found in nearly every to-do app on the Australian App Store top charts. Combine that with a fetched results controller section name, and you have a tabbed interface for under fifty lines of code.

Polishing, testing and shipping with care

Unit tests for Core Data require an in-memory store. Inject the NSPersistentStoreDescription with type NSInMemoryStoreType, then construct your container as usual inside setUp(). Tests run fast, stay hermetic, and your CI on Bitrise or GitHub Actions does not leak state between runs. A common interview question in Sydney consulting teams is how you tested a model migration; an in-memory store is half the answer.

Persistence work rewards patience, the same way that waiting for a quiet morning on a Murray River flat rewards those who study the tench fishing journal. The work is rarely flashy, but it pays off when your app survives a real device interruption such as a low-memory warning or a forced restart.

Before you ship, double-check your privacy manifest. Apple now requires a PrivacyInfo.xcprivacy file that declares any user-tracking or data-linking reasons, and any access to UserDefaults or file timestamp APIs. Pair that with a clear privacy policy on the App Store listing, and you have met both Apple guidelines and the basics of the Australian Consumer Law for digital goods.

Habits that move the project forward

  • Sketch the entity diagram on paper before touching Xcode; an hour of design saves a day of refactoring.
  • Keep Codegen on Manual/None once you start writing your own subclasses; the duplication bites quickly.
  • Treat the view context as read-only when you can: push heavy edits to a background context.
  • Use NSFetchedResultsController at least once, even if you later adopt SwiftUI, so you understand how diffing really works.
  • Add a unit test that loads the store, inserts one Task, and asserts it comes back from a fresh fetch.
  • Ship a Release build to TestFlight before you polish, so performance surprises show up on a real device.
  • Read the App Store Review Guidelines once a quarter; they shift between WWDCs and catch out first-time submitters.

The most concrete thing you can do after reading this is open Xcode, create the empty Task entity, and push a first working commit to GitHub before your morning coffee cools. Once the build is stable, publish a brief project write-up on iosdev.kz as your next concrete step.

Updated: