Understanding the importance of abstractions

Published by donnywals on

As developers, we constantly deal with layers of abstractions that make our lives easier. We have abstractions over low level networking operations that allow us to make network calls with URLSession. Core Data provides an abstraction over data persistence that can be used to store information in an sqlite database. And there are many, many more abstractions that we all use every day.

Over the past few weeks I have seen many people ask about using Core Data in pure SwiftUI projects created in Xcode 12. These projects no longer require an App- and SceneDelegate, and the checkbox to add Core Data is disabled for these projects. Some folks immediately thought that this meant Core Data can't be used with these projects since Xcode's template always initialized Core Data in the AppDelegate, and since that no longer exists it seems to make sense that Core Data is incompatible with apps that don't have an AppDelegate. How else would you initialize Core Data?

Fortunately, this isn't true. It's still possible to use Core Data in projects, even if they don't have an AppDelegate. In fact, the only thing that AppDelegate has to do with Core Data is that Apple decided that they wanted to setup Core Data in the AppDelegate.

They didn't have to make that choice. Core Data can be initialized from anywhere in your app.

However, this got me thinking about abstractions. Folks who have built a layer of abstraction between their app and Core Data probably already know that you don't need Xcode to generate a Core Data stack for you. They probably also already know that you can initialize Core Data anywhere.

While thinking about this, I started thinking more about abstractions. Adding the right abstractions to your app at the right time can help you build a more modular, portable and flexible code base that can quickly adapt to changes and new paradigms.

That why in this week's post, I would like to talk about abstractions.

Understanding what abstractions are

Abstractions provide a seperation between the interface you program against and the underlying implementation that performs work. In essence you can think of most, if not all, frameworks you use every day on iOS as abstractions that make working with something complex easier.

In programming, we often work with abstractions on top of abstractions on top of more abstractions. And yet, there is value in adding more abstractions yourself. A good abstraction does not only hide complexity and implementation details. It should also be reusable. When your abstraction is reusable it can be used in multiple projects with similar needs.

I could try to make the explanation more wordy, fancy or impressive but that wouldn't help anybody. Abstractions wrap a complex interface and provide an (often simpler) inferface while hiding the wrapped, complex interface as an implementation detail. Good abstractions can be reused.

Knowing when to write an abstraction

Earlier I wrote that adding your own abstractions has value. That said, it's not always obvious to know when you should write an abstraction. Especially since there are no hard or clear rules.

A good starting point for me is to determine whether I will write a certain block of tedious code more than once. Or rather, whether I will write similar blocks of tedious code multiple times. If the answer is yes, it makes sense to try and create a lightweight abstraction to wrap the tedious code and make it less annoying to work with.

Another method I often use to determine whether I should write an abstraction is to ask myself how easily I want to be able to swap a certain mechanism in my app out for testing or to replace it entirely.

Usually the answer to this question is that I want to be able to swap things out as easily as possible. And more often than not this means that I should add an abstraction.

For instance, when I write code that uses Core Data I always wrap it in a small abstraction layer. I don't want my entire app to depend directly on Core Data. Instead, my app uses the abstraction to interface with a persistence layer. The code in my app doesn't know how the persistence layer works. It just knows that such a layer exists, and that it can fetch and save objects of certain types.

Creating an abstraction like this allows me to easily change the underlying storage mechanism in my persistence layer. I could switch to Realm, use sqlite directly, or even move from local persitence to persisting data on a server or in iCloud. The app shouldn't know, and the app shouldn't care. That's the beauty of abstractions.

Designing an abstraction

Once you've decided that you want to write an abstraction, you need to design it. The first thing I always do is make sure that I decide which properties and methods should be publicly available. I then define a protocol that captures this public API for my abstraction. For example:

protocol TodoItemPersisting {
  func getAllTodoItems() -> Future<[TodoItem]>
  func getTodoItem(withId id: UUID) -> Future<TodoItem?>
  func updateItem(_ item: TodoItem)
  func newTodoItem() -> Future<TodoItem>
}

This is a very simple protocol that exposes nothing about the underlying persistence layer. In the rest of my code I will always refer to TodoItemPersisting when I want to use my persistence abstraction:

struct TodoListViewModel {
  private let itemStore: TodoItemPersisting
}

In this example I defined a ViewModel that has an itemStore property. This property conforms to TodoItemPersisting and the object that creates an instance of TodoListViewModel gets to decide which concrete implementation of TodoItemPersisting is injected. And since the protocol for TodoItemPersisting uses Combine Futures, we know that the persistence layer does work asynchronously. The ViewModel doesn't know whether the persistence layer goes to the network, file system, Core Data, Realm, Firebase, iCloud or anywhere else for persistence.

It just knows that items are fetched and created asynchronously.

At this point you're free to create objects that implement TodoItemPersisting as needed. Usually you'll have one or two. One for the app to use, and a second version to use while testing. But you might have more in your app. It depends on the abstraction and what it's used for.

For instance, if your app uses In-App Purchases to provide syncing data to a server you might have a local persistence abstraction, and a premium local + remote persistence abstraction that you can swap out depending on whether the user bought your premium IAP.

By desiginig abstractions as protocols you gain a lot of flexibility and power. So whenever possible I always recommend to design and define your abstractions as protocols.

Things to watch out for when writing abstractions

Once you get the hang of abstracting code, it's very tempting to go overboard. While abstractions provide a lot of power, they also add a layer of indirection. New members of your team might understand the things you've abstracted really well, but if you added to many layers your code will be really hard to understand and your abstractions will be in the way of understanding the code base.

It's also possible that you didn't design your abstractions properly. When this happens, you will find that your abstractions are holding you back rather than helping you write code that does exactly what you want it to do. When you find you're fighting your abstractions it's time to revise your design and make improvements where needed.

And the last word of warning I want to give you is that it's important to limit the levels of abstractions you add. No matter how good your abstractions are, there will come a point where it'll get harder and harder to understand and debug your app when something is wrong. There's no hard cutoff point but eventually you'll develop a sense for when you're going too far. For now it's good to know that you can abstract too much.

In Summary

In this week's post you learned about abstractions in programming. You learned what an abstraction is, what abstractions are used for and how you can determine whether you should write an abstraction of your own.

You learned that abstractions can be extremely useful when you want to write code that's testible, flexible, and maintainable. Good abstractions make difficult work easier, and allow you to hide all implementation details of the thing or process you've written your abstraction for. You also learned that protocols are a fantastic tool to help you define and design your abstraction. Lastly, I gave you some things to watch out for when writing abstractions to make sure you don't overcomplicate matters or abstract too much.

If you have any questions for me, or if you have feedback about this week's post make sure to reach out to me on Twitter.


Practical Combine

Learn everything you need to know about Combine and how you can use it in your projects with my new book Practical Combine. You'll get thirteen chapters, a Playground and a handful of sample projects to help you get up and running with Combine as soon as possible.

The book is available as a digital download for just $24.99!

Get Practical Combine

Receive weekly updates about my posts