Transactions and Rolling Back Changes in Core Data with UnitOfWork

The Unit of Work pattern is a code equivalent to database transactions: when it completes, changes are persisted; when something fails, changes are rolled back.

That’s handy to perform a set of changes and have them saved. Sooner or later during app development using Core Data, you may ask: when should I save? When is the best point in time?

The rough answer is: whenever the user completed a task.

That means you should not save intermediate changes in a modally presented view controller scene which can be cancelled. Cancelling equals discarding all changes made in the form.

In most of my own apps, the answer is more specific: during most use case objects. I architect most apps around use cases, represented as small service objects. (Application Services, to be exact.) They perform a user-initiated change and save the result. They’re the sole users of Units of Work. View controllers merely delegate user interactions to event handlers; the action itself is contained in these services.

In Core Data, you can achieve discarding of changes to managed objects with child managed object contexts. Just get rid of the context.

Handling this manually introduces repetition, though. I’m quite content with code like this:

// Application Service
class RemoveFile {
    let fileStore: FileStore
    
    init(fileStore: FileStore) {
        self.fileStore = fileStore
    }

    func removeFiles(fileIDs: [FileID]) {
        guard !fileIDs.isEmpty else { return }
    
        unitOfWork()
            // TODO: Report failure to user :)
            .onError { fatalError($0) }
            .execute {
                let remover = fileRemovalService()
                for fileID in fileIDs {
                    try remover.removeFile(fileID)
                }
        }
    }
    
    // Testing seam: 
    // Override this factory method during tests to inject a mock
    func fileRemovalService() -> FileRemoval {
        // A Domain Service adhering to business rules to remove files.
        return FileRemoval(fileStore: fileStore)
    }
}
  • The real transaction spans a set of entities, so failure in entity number N should roll back changes to entities 0...N-1. That’s why the Unit of Work wraps the loop.
  • To roll-back changes, simply throw during execute’s block.
  • Conversely, re-throw errors that pop up during execution.

For an Application Service, this is very straightforward. That’s why I picked the example.

The actual Unit of Work looks like this:

let mainContext: NSManagedObjectContext = ...

func unitOfWork() -> UnitOfWork {
    return UnitOfWork(parentManagedObjectContext: mainContext)
}

enum UnitOfWorkError: Error {
    /// Error raised during `performBlock` of the execute closure.
    case executionError(NSError)

    /// Error raised when the temporaty transactional context cannot be saved.
    case coreDataTransactionError(NSError)

    /// Error raised when the main context cannot be saved after the transaction.
    case coreDataError(NSError)
}

let isRunningTests = ProcessInfo.processInfo 
    .environment["XCTestConfigurationFilePath"] != nil

struct UnitOfWork {
    let parentManagedObjectContext: NSManagedObjectContext
    let managedObjectContext: NSManagedObjectContext

    var errorHandler: ((UnitOfWorkError) -> Void)?

    init(
        parentManagedObjectContext: NSManagedObjectContext,
        managedObjectContext: NSManagedObjectContext
    ) {
        self.parentManagedObjectContext = parentManagedObjectContext
        self.managedObjectContext = managedObjectContext
    }

    func onError(handleError: (UnitOfWorkError) -> Void) -> UnitOfWork {
        errorHandler = handleError
        return self
    }
    
    func execute(closure: () throws -> Void) {
        managedObjectContext.performBlock {
            do {
                try closure()
            } catch {
                self.errorHandler?(.executionError(error))
                return
            }
    
            do {
                try self.managedObjectContext.save()
            } catch {
                self.errorHandler?(.coreDataTransactionError(error))
                return
            }
        
            self.parentManagedObjectContext.performBlock() {
                do {
                    try self.parentManagedObjectContext.save()
                } catch let error as NSError {
                    self.errorHandler?(.coreDataError(error))
                }
            }
        }
    }
}

When saving to the transaction’s managedObjectContext succeeds, the changes are effectively propagated to the parent store which has to be saved right afterward, too.

I’d rather have the UnitOfWork throw errors itself instead of using the kind of weird errorHandler. But performBlock is executed asynchronously, which is useful, so there’s no deal-breaking, non-blocking way to capture errors of the block and throw them from execute again.