Adaptable Interactions with Core Data

Subscribe to my newsletter and never miss my upcoming articles

Core Data has evolved over the years as one of Apple's most valued frameworks. Within a matter of minutes, a developer can add an object management persistence system that supports a long list of built-in features such as change tracking, undo/redo functionality and more recently cloud synchronization.

The framework also provides some convenient drop-in components (FetchRequest, NSFetchedResultsController), giving common UI elements the ability to interact with Core Data objects with minimal effort.

As an app and its code base grows, however, you might realize that using those components can result in views that host too much logic, making it hard if not impossible to unit test these interactions or reuse them.

Persistence layer code is most susceptible to change, making it critical that such logic should be flexible, maintainable and testable. What approach can we take to make our Core Data logic adhere to these requirements?

Clean code advocates would suggest completely abstracting the Core Data implementation behind protocols. This may include:

  • Repository protocols for each entity model
  • A separate adapter model for each Core Data object model (i.e Todo -> ManagedTodo)

Doing the above will not only make our code testable and portable, but future proof it as well. Abstracting our persistence layer behind protocols gives us the ability to swap out implementations as we see fit (Core Data, Realm, Firebase) with limited code changes.

As we add multiple entity types and relationships, however, that type of protocol abstraction can result in a lot of boilerplate and complexity. Before we buy in to this approach with our time and resources, maybe we should take a moment and ask ourselves an important question. Are we really going to need it?

YAGNI (You Aren't Gonna Need It)

Let's face, It would be an extremely rare case where we would need to change our implementation from Core Data to anything else. Having experienced the time sink and heavy boilerplate associated with persistence layer abstraction, I would not recommend it unless you have a team of developers or experimenting with a new database. The time spent preparing for some foreseeable circumstance can be better spent getting products or features out the door faster.

But that doesn't mean there aren't going to be other changes coming our way. Most fast go-to-market strategies are all about iteration. Iteration being a keyword here. This implies that changes to our code are inevitable. So how can we go about ensuring our Core Data logic is best suited to adapt to the changes that lie ahead?

Theming abstractions around interactions

Let's try another approach where instead of framing our abstractions around separating implementation logic, we formulate them on interactions instead.

If we were to map out persistence interactions for a Todo app, for instance, the list of possible actions could include:

  • Adding a Todo
  • Updating a Todo
  • Deleting a Todo
  • Fetching a list of Todos

Let's see what an interaction implementation of adding a Todo item might look like:

class AddTodoCase {
    @Inject
    private var viewContext: NSManagedObjectContext

    struct Input {
        let title: String
    }

    func call(_ input: Input) -> AnyPublisher<Todo, Error> {
        Deferred { [viewContext] in
            Future { promise in
                viewContext.perform {
                    let todo = Todo(context: viewContext)
                    todo.title = input.title
                    todo.createdAt = Date()
                    do {
                        try viewContext.save()
                        promise(.success(todo))
                    } catch {
                        promise(.failure(error))
                    }
                }
            }
        }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
    }
}

In the code above, we define the interaction of saving a newly added Todo into its own class. For naming consistency, we describe the class's name as a use case. Each use case has a callable (i.e call()) method that takes an Input and produces a result. For the case of persisting a new Todo, the signature of the method is a Publisher to account for its async nature.

Under the hood, the method will create the Todo Core Data object and persist it, catching and communicating any errors to a subscriber. Notice we supply the NSManagedObjectContext to the class via an injection property wrapper, eliminating the need for consumers of the class to know about its construction.

Let's see how a view model would use this use case:

class ViewModel: ObservableObject {
    @Published
    var loading = false
    private let addTodoCase = AddTodoCase()
    private var cancellables = Set<AnyCancellable>()

    func addTodo(title: String) {
        loading = true
        let input = AddTodoCase.Input(title: title)
        addTodoCase.call(input)
            .sink { [weak self] completion in
                switch completion {
                case .failure:
                    debugPrint("something isn't right")
                    self?.loading = false
                case .finished:
                    break
                }
            } receiveValue: { [weak self] todo in
                self?.loading = false
                debugPrint("\(todo) added")
            }
            .store(in: &cancellables)

    }
}

Note how our view model is oblivious to how the Todo is saved, decoupling it from any persistence logic. Such ignorance allows us to blissfully iterate our logic with minimal code changes.

Let's assume we later learn that some of our users want to categorize their Todo items. As a result, we add the Category entity to our model editor and define the relationship. We then update our use case to accept the optional category parameter:

class AddTodoCase {
    @Inject
    private var viewContext: NSManagedObjectContext

    struct Input {
        let title: String
        var category: Category? = nil
    }

    func call(_ input: Input) -> AnyPublisher<Todo, Error> {
        Deferred { [viewContext] in
            Future { promise in
                viewContext.perform {
                    let todo = Todo(context: viewContext)
                    todo.title = input.title
                    todo.createdAt = Date()
                    todo.category = input.category
                    do {
                        try viewContext.save()
                        promise(.success(todo))
                    } catch {
                        promise(.failure(error))
                    }
                }
            }
        }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
    }
}

In this circumstance, we aren't required to make any changes to our view model!

Additional benefits of this approach:

  • Persistence logic is separated, allowing us to reuse the class in more than one view model or even use case
  • By using property wrapper injection, our use cases can add or remove dependencies without resulting in any changes to its consumers
  • By defining an Input model for each use case, we have the flexibility of adding parameters without needing to change the definition of its methods
  • We have a nice repeatable pattern for defining interactions in our code base

One aspect of this approach that we are yet to touch upon is testability. By avoiding abstracted protocols, our unit tests won't be able to provide mock implementations or mocked entities for our persistence logic. But that doesn't mean our approach isn't testable. Luckily Core Data comes with an in-memory persistent store that works really well for unit tests!

Conclusion

Persistence layer code is most susceptible to change as an app evolves and adds new features. By organizing that logic around interactions, we can increase the adaptability of our code base and be better prepared for the changes that lie ahead.

I hope you enjoyed this article and found it useful. If you have any questions, comments, or feedback i'd love to hear them. Contact me or follow me on Twitter