Chapter 14: Final Polish: The Complete Expense Tracker App
1. The Architectural Blueprint
- Testability: By hiding Core Data behind a Repository protocol, we can inject a mock repository into our ViewModels for unit testing and SwiftUI Previews. You cannot easily mock
@Environment(\.managedObjectContext). - Separation of Concerns: Views should only format and display data. They should not contain database query logic (predicates, sort descriptors).
- Scalability: When your app grows, you might want to switch your local cache from Core Data to SwiftData, Realm, or a simple file-based system. With a Repository pattern, your UI layer remains completely untouched.
- Performance Control:
@FetchRequestbinds the lifecycle of a database query to the lifecycle of a View. If the view redraws frequently, it can cause unintended performance overhead. By controlling the fetch lifecycle in the ViewModel, we dictate exactly when and how data is queried.
The Rules of Engagement
- Views Know Nothing: SwiftUI views only interact with ViewModels. They observe
@Observableproperties and call methods on the ViewModel. They never importCoreData. - ViewModels Orchestrate: ViewModels transform domain data (like
NSManagedObjectsubclasses) into presentation data. They hold references to Repositories or our customNSFetchedResultsControllerwrappers. - Repositories Abstract: Repositories handle the actual fetching, saving, deleting, and instantiating of data.
- The Stack Endures: The
CoreDataManagersingleton encapsulates theNSPersistentContainerand its contexts, ensuring thread safety and proper context merging.
2. Solidifying the Core Data Stack
import CoreData
import Foundation
public final class CoreDataManager {
/// A shared singleton instance. While Singletons can be an anti-pattern,
/// having a single source of truth for the Core Data stack is highly recommended.
public static let shared = CoreDataManager()
public let persistentContainer: NSPersistentContainer
/// A convenience accessor for the main queue context.
/// NEVER use this context on a background thread.
public var viewContext: NSManagedObjectContext {
persistentContainer.viewContext
}
/// Initializes the Core Data stack.
/// - Parameter inMemory: If true, creates an in-memory store. Crucial for Unit Tests and SwiftUI Previews.
private init(inMemory: Bool = false) {
persistentContainer = NSPersistentContainer(name: "ExpenseTrackerModel")
if inMemory {
let description = NSPersistentStoreDescription()
// Setting the URL to /dev/null creates a true in-memory SQLite store.
// This is generally faster and more accurate than NSInMemoryStoreType.
description.url = URL(fileURLWithPath: "/dev/null")
persistentContainer.persistentStoreDescriptions = [description]
}
// Essential configuration for modern Core Data applications
guard let description = persistentContainer.persistentStoreDescriptions.first else {
fatalError("Failed to retrieve a persistent store description.")
}
// Enables Persistent History Tracking. This is mandatory if you plan to share the database
// with an App Extension (like a Widget) or sync with CloudKit.
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
// Tells Core Data to post notifications when the store changes remotely (e.g., from an App Group extension)
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
// Lightweight migration is enabled by default in modern iOS, but it's good practice to be explicit.
description.shouldInferMappingModelAutomatically = true
description.shouldMigrateStoreAutomatically = true
persistentContainer.loadPersistentStores { (storeDescription, error) in
if let error = error as NSError? {
// EXTREME CAUTION: In production, log this to a crash reporter (e.g., Crashlytics, Sentry).
// Do not use fatalError unless you want your users to experience hard crashes on launch due to a corrupted DB.
// A robust app might attempt to delete the corrupted store and rebuild it here.
print("Unresolved error \(error), \(error.userInfo)")
}
}
// Automatically merges changes saved to its parent store (or background contexts) into the viewContext.
// MEMORY IMPLICATION: This keeps the viewContext fresh, but can lead to memory bloat if thousands
// of objects are created in the background. If you do massive batch imports, consider batching the saves.
persistentContainer.viewContext.automaticallyMergesChangesFromParent = true
// Merge Policies: NSMergeByPropertyObjectTrumpMergePolicy ensures that if the in-memory object
// on the context has been modified, its changes win over what's currently on disk.
persistentContainer.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
/// Vends a new background context for heavy data processing.
public func newBackgroundContext() -> NSManagedObjectContext {
let context = persistentContainer.newBackgroundContext()
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
// Give background contexts a name for easier debugging in Instruments.
context.name = "Background_Importer_Context"
return context
}
/// Safely saves the view context if there are changes.
public func saveContext() {
let context = persistentContainer.viewContext
// ALWAYS check hasChanges before saving to avoid unnecessary disk I/O.
if context.hasChanges {
do {
try context.save()
} catch {
let nserror = error as NSError
// Handle save errors gracefully. Present an alert to the user in the UI layer.
print("Unresolved error saving context: \(nserror), \(nserror.userInfo)")
}
}
}
}
Key Takeaways for the Stack:
automaticallyMergesChangesFromParent = true: This is critical for our architecture. When we perform a save operation on a background context (vianewBackgroundContext), Core Data will broadcast a notification. Because this flag is true, theviewContextautomatically consumes that notification, merges the changes, and triggers ourNSFetchedResultsControllerdelegate, which in turn updates our SwiftUI views. All without writing any manual notification observers!/dev/nullfor In-Memory Stores: Notice we use an SQLite store pointing to/dev/nullrather thanNSInMemoryStoreType. The legacyNSInMemoryStoreTypelacks support for advanced SQLite features (like specific migration edge cases or fetch request behaviors). The/dev/nulltrick provides an identical SQLite environment that simply evaporates when the app terminates.
3. The NSFetchedResultsController Wrapper
import CoreData
import Combine
import Observation
/// A generic wrapper that adapts NSFetchedResultsController for use with Swift Concurrency and SwiftUI.
@Observable public class FetchControllerWrapper<T: NSManagedObject>: NSObject, NSFetchedResultsControllerDelegate {
public var fetchedObjects: [T] = []
public var sections: [NSFetchedResultsSectionInfo] = []
public let controller: NSFetchedResultsController<T>
/// Initializes the wrapper with a fetch request.
/// - Parameters:
/// - fetchRequest: The request defining the data to fetch. **Must** include at least one sort descriptor.
/// - managedObjectContext: The context to fetch against (usually the main viewContext).
/// - sectionNameKeyPath: A key path on the entity to group results into sections.
/// - cacheName: A name for the FRC cache. Use cautiously!
public init(fetchRequest: NSFetchRequest<T>,
managedObjectContext: NSManagedObjectContext,
sectionNameKeyPath: String? = nil,
cacheName: String? = nil) {
// Edge Case Protection: FRC requires sort descriptors.
if fetchRequest.sortDescriptors == nil || fetchRequest.sortDescriptors?.isEmpty == true {
assertionFailure("FetchControllerWrapper requires a fetch request with at least one sort descriptor.")
}
self.controller = NSFetchedResultsController(
fetchRequest: fetchRequest,
managedObjectContext: managedObjectContext,
sectionNameKeyPath: sectionNameKeyPath,
cacheName: cacheName
)
super.init()
self.controller.delegate = self
}
/// Executes the initial fetch. Must be called explicitly after initialization.
public func performFetch() {
do {
try controller.performFetch()
updateState()
} catch {
print("Failed to perform fetch in wrapper: \(error)")
}
}
/// Syncs the FRC's internal state to our @Observable properties.
private func updateState() {
// We reassign the array. Because the objects are NSManagedObjects (reference types),
// and because SwiftUI's ForEach uses the object's `id` (or `objectID`), SwiftUI is incredibly
// smart about computing the diff and only animating the changed rows, even if we reassign the whole array.
fetchedObjects = controller.fetchedObjects ?? []
sections = controller.sections ?? []
}
// MARK: - NSFetchedResultsControllerDelegate
/// Called when the context has processed changes that affect the fetched results.
public func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
// PERFORMANCE TIP: If you have a very active database (e.g., syncing hundreds of items in the background),
// `controllerDidChangeContent` can fire rapidly. In a highly stressed app, you might want to debounce
// this call before updating the @Observable properties to prevent frame drops in SwiftUI.
updateState()
}
}
Deep Dive: FRC Caching and Memory
cacheNamepitfalls: Providing acacheNametells the FRC to pre-compute the sections and sort order and write them to disk. This is a massive performance boost for complex, sectioned lists. However, if you ever change theNSPredicateorNSSortDescriptordynamically, you must callNSFetchedResultsController.deleteCache(withName:)before performing the new fetch, or your app will crash or display corrupt data.- Faulting: By default,
NSFetchRequestreturns "faults" (hollow shells of objects).fetchedObjectsmight contain 10,000 items, but it uses almost no RAM until SwiftUI actually scrolls a specific row into view and accesses theexpense.amountproperty, which "fires the fault" and loads the data from SQLite.
4. The Repositories: Abstracting Core Data
The Expense Repository
import Foundation
import CoreData
public protocol ExpenseRepositoryProtocol {
func addExpense(amount: Double, date: Date, note: String, category: CategoryEntity)
func deleteExpense(_ expense: ExpenseEntity)
func getTotalExpenses(for month: Date) -> Double
func getExpensesGroupedByCategory(for month: Date) -> [(category: String, amount: Double)]
}
public class ExpenseRepository: ExpenseRepositoryProtocol {
private let context: NSManagedObjectContext
// Dependency injection allows us to pass a specific context (like a background context) if needed.
public init(context: NSManagedObjectContext = CoreDataManager.shared.viewContext) {
self.context = context
}
public func addExpense(amount: Double, date: Date, note: String, category: CategoryEntity) {
// ALWAYS perform operations on the context's queue to avoid thread-safety crashes.
// Since we usually inject the viewContext, performAndWait ensures it runs on the main thread safely.
context.performAndWait {
let expense = ExpenseEntity(context: context)
expense.id = UUID()
expense.amount = amount
expense.date = date
expense.note = note
expense.category = category
saveContext()
}
}
public func deleteExpense(_ expense: ExpenseEntity) {
context.performAndWait {
context.delete(expense)
saveContext()
}
}
/// Calculates the sum of all expenses for a given month using an SQLite-level SUM operation.
public func getTotalExpenses(for month: Date) -> Double {
var total: Double = 0.0
// Thread safety is paramount. Contexts are bound to specific queues.
context.performAndWait {
let calendar = Calendar.current
guard let startOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: month)),
let endOfMonth = calendar.date(byAdding: DateComponents(month: 1, day: -1), to: startOfMonth) else {
return
}
// 1. We request a Dictionary instead of Managed Objects. This is extremely lightweight.
let request: NSFetchRequest<NSDictionaryResultType> = NSFetchRequest(entityName: "ExpenseEntity")
request.resultType = .dictionaryResultType
// 2. Filter to the specific month
request.predicate = NSPredicate(format: "date >= %@ AND date <= %@", startOfMonth as NSDate, endOfMonth as NSDate)
// 3. Create the SQLite SUM function definition
let sumExpressionDesc = NSExpressionDescription()
sumExpressionDesc.name = "totalSum"
// The function "sum:" applies to the keyPath "amount"
sumExpressionDesc.expression = NSExpression(forFunction: "sum:", arguments: [NSExpression(forKeyPath: "amount")])
sumExpressionDesc.expressionResultType = .doubleAttributeType
request.propertiesToFetch = [sumExpressionDesc]
do {
let results = try context.fetch(request)
if let resultDict = results.first as? [String: Double], let sum = resultDict["totalSum"] {
total = sum
}
} catch {
print("Error calculating total expenses: \(error)")
}
}
return total
}
/// Groups expenses by category and sums them up using an SQLite-level GROUP BY.
public func getExpensesGroupedByCategory(for month: Date) -> [(category: String, amount: Double)] {
var groupedResults: [(category: String, amount: Double)] = []
context.performAndWait {
let calendar = Calendar.current
guard let startOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: month)),
let endOfMonth = calendar.date(byAdding: DateComponents(month: 1, day: -1), to: startOfMonth) else {
return
}
let request: NSFetchRequest<NSDictionaryResultType> = NSFetchRequest(entityName: "ExpenseEntity")
request.resultType = .dictionaryResultType
request.predicate = NSPredicate(format: "date >= %@ AND date <= %@", startOfMonth as NSDate, endOfMonth as NSDate)
let sumExpressionDesc = NSExpressionDescription()
sumExpressionDesc.name = "categoryTotal"
sumExpressionDesc.expression = NSExpression(forFunction: "sum:", arguments: [NSExpression(forKeyPath: "amount")])
sumExpressionDesc.expressionResultType = .doubleAttributeType
// 4. GROUP BY setup. We tell SQLite to fetch the category name and the sum description, and group by the name.
request.propertiesToFetch = ["category.name", sumExpressionDesc]
request.propertiesToGroupBy = ["category.name"]
do {
let results = try context.fetch(request)
groupedResults = results.compactMap { dict in
guard let categoryName = dict["category.name"] as? String,
let total = dict["categoryTotal"] as? Double else { return nil }
return (category: categoryName, amount: total)
}
} catch {
print("Error grouping expenses: \(error)")
}
}
return groupedResults
}
private func saveContext() {
if context.hasChanges {
do { try context.save() } catch { print("Save error: \(error)") }
}
}
}
[!TIP] Database-Level Optimization vs Application-Level Math
If we had used standard SwiftUI patterns, we would fetch a[ExpenseEntity]array into memory, and then use Swift'sreduceto sum them up. For 10,000 transactions, theNSExpressionDescriptionmethod takes ~2ms and negligible memory because the math happens inside the C/C++ SQLite engine. The array/reduce method would take ~50-100ms and cause massive memory spikes as 10,000 objects are faulted into the context.
5. The ViewModels
Expense List ViewModel
import Foundation
import CoreData
import Observation
import Combine
@Observable public class ExpenseListViewModel {
public var sections: [NSFetchedResultsSectionInfo] = []
private let repository: ExpenseRepositoryProtocol
public let fetchControllerWrapper: FetchControllerWrapper<ExpenseEntity>
private var cancellables = Set<AnyCancellable>()
public init(repository: ExpenseRepositoryProtocol = ExpenseRepository()) {
self.repository = repository
// 1. Create the Fetch Request
let request: NSFetchRequest<ExpenseEntity> = ExpenseEntity.fetchRequest()
// Primary sort MUST match the section grouping logically (e.g., date descending).
let dateSort = NSSortDescriptor(keyPath: \ExpenseEntity.date, ascending: false)
request.sortDescriptors = [dateSort]
// MEMORY IMPLICATION: fetchBatchSize is Core Data's secret weapon.
// Even if the DB has 1,000,000 rows, this ensures only 20 objects are fully materialized
// into memory at any given time. As SwiftUI scrolls, Core Data transparently faults and un-faults batches.
request.fetchBatchSize = 20
// 2. Initialize the Wrapper with sectionNameKeyPath
// We assume we have a transient property `@objc var monthYearString: String` on ExpenseEntity.
self.fetchControllerWrapper = FetchControllerWrapper(
fetchRequest: request,
managedObjectContext: CoreDataManager.shared.viewContext,
sectionNameKeyPath: "monthYearString" // Crucial for Sectioned Lists
)
// 3. Bind the wrapper's sections to our own property.
// We ensure delivery on the main thread since SwiftUI views observe this.
// (Note: In a pure Observation approach, you might simply reference fetchControllerWrapper.sections directly
// in your View, or observe the wrapper directly).
self.fetchControllerWrapper.performFetch()
}
public func delete(at indexSet: IndexSet, in section: NSFetchedResultsSectionInfo) {
guard let objects = section.objects as? [ExpenseEntity] else { return }
for index in indexSet {
let expense = objects[index]
repository.deleteExpense(expense)
}
}
}
[!IMPORTANT] Transient Properties for Sections: To group an
NSFetchedResultsControllerby month and year, create an extension on yourExpenseEntitywith an@objccomputed property that returns a formatted string (e.g., "October 2023"). Use this property name as thesectionNameKeyPath. Because it's computed on the fly, it doesn't take up database space, but Core Data can use it to organize the fetched results array into sections!
Dashboard ViewModel
import Foundation
import Combine
import SwiftUI
import CoreData
import Observation
public struct CategoryChartData: Identifiable {
public let id = UUID()
public let category: String
public let amount: Double
}
@Observable public class DashboardViewModel {
public var totalMonthlyExpense: Double = 0.0
public var categoryData: [CategoryChartData] = []
private let repository: ExpenseRepositoryProtocol
private var cancellables = Set<AnyCancellable>()
public init(repository: ExpenseRepositoryProtocol = ExpenseRepository()) {
self.repository = repository
// Listen for ANY Core Data saves to refresh dashboard metrics automatically.
// This is a broader net than FRC, but necessary for aggregate queries.
NotificationCenter.default.publisher(for: .NSManagedObjectContextDidSave)
.receive(on: RunLoop.main)
.sink { [weak self] notification in
// Edge Case Optimization: Only refresh if the save came from the viewContext
// or if it was merged into it. You can inspect `notification.userInfo` for
// inserted/updated/deleted objects to see if an ExpenseEntity actually changed.
self?.refreshData()
}
.store(in: &cancellables)
refreshData()
}
public func refreshData() {
let now = Date()
self.totalMonthlyExpense = repository.getTotalExpenses(for: now)
let rawData = repository.getExpensesGroupedByCategory(for: now)
// Map domain tuples to View-specific structs suitable for Swift Charts.
self.categoryData = rawData.map { CategoryChartData(category: $0.category, amount: $0.amount) }
}
}
6. The Final SwiftUI UI
The Dashboard (Integrating Swift Charts)
import SwiftUI
import Charts
struct DashboardView: View {
@State private var viewModel = DashboardViewModel()
@State private var showingAddExpense = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// Summary Card
VStack(spacing: 8) {
Text("This Month's Spend")
.font(.subheadline)
.foregroundColor(.secondary)
Text(viewModel.totalMonthlyExpense, format: .currency(code: "USD"))
.font(.system(size: 42, weight: .bold, design: .rounded))
.foregroundColor(.primary)
}
.padding()
.frame(maxWidth: .infinity)
.background(Color(UIColor.secondarySystemBackground))
.cornerRadius(16)
.padding(.horizontal)
// Spending by Category Chart
VStack(alignment: .leading) {
Text("Top Categories")
.font(.headline)
.padding(.horizontal)
if viewModel.categoryData.isEmpty {
Text("No expenses this month.")
.foregroundColor(.secondary)
.padding()
} else {
Chart(viewModel.categoryData) { item in
BarMark(
x: .value("Amount", item.amount),
y: .value("Category", item.category)
)
// Dynamically color bars by category name
.foregroundStyle(by: .value("Category", item.category))
.annotation(position: .trailing) {
Text(item.amount, format: .currency(code: "USD"))
.font(.caption)
.foregroundColor(.secondary)
}
}
.frame(height: 300)
.padding()
}
}
}
.padding(.vertical)
}
.navigationTitle("Dashboard")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { showingAddExpense = true }) {
Image(systemName: "plus.circle.fill")
.font(.title2)
}
}
}
.sheet(isPresented: $showingAddExpense) {
// E.g., AddExpenseView(viewModel: AddExpenseViewModel())
Text("Add Expense View (Implementation Omitted)")
}
}
}
}
The Sectioned Expense List
import SwiftUI
import CoreData
struct ExpenseListView: View {
// We use @State to ensure the ViewModel (and its FRC) are only initialized once per view lifecycle.
@State private var viewModel = ExpenseListViewModel()
var body: some View {
NavigationStack {
List {
// Iterate over FRC sections
ForEach(viewModel.fetchControllerWrapper.sections, id: \.name) { section in
Section(header: Text(section.name)) {
// Safely cast the generic objects array to our specific entity
if let expenses = section.objects as? [ExpenseEntity] {
// By using \.objectID, we give SwiftUI a stable identifier even if data changes
ForEach(expenses, id: \.objectID) { expense in
ExpenseRow(expense: expense)
}
.onDelete { indexSet in
// Pass the deletion command back to the ViewModel
viewModel.delete(at: indexSet, in: section)
}
}
}
}
}
.listStyle(.insetGrouped)
.navigationTitle("Transactions")
}
}
}
struct ExpenseRow: View {
// EXTREMELY IMPORTANT: By making the property @Bindable, this specific row
// view will automatically re-render ONLY if this specific ExpenseEntity changes!
// This allows us to push edits to a detail screen, pop back, and see the row instantly update.
@Bindable var expense: ExpenseEntity
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(expense.note ?? "Unknown")
.font(.headline)
Text(expense.category?.name ?? "Uncategorized")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Text(expense.amount, format: .currency(code: "USD"))
.fontWeight(.semibold)
.foregroundColor(expense.amount > 1000 ? .red : .primary)
}
}
}
[!NOTE] Why
@Bindableon the Row?NSManagedObjectconforms toObservablein modern Swift via an extension or native bridging. By wrapping theexpensein@Bindableinside the subview, SwiftUI will automatically re-render just that row if a specific attribute changes. If we didn't do this, editing an item deep in the navigation stack might not reflect when you pop back to the list, unless the entire FRC array triggered a redraw.