Now we can realise the beauty of a Data Access Layer - we can use any and all of these approaches to fetching data, and synthesise a result with the most up-to-date data, as quickly as possible.
We're in the home stretch now. In the penultimate chapter in our async testing odyssey, we're doing something a bit different to prepare for the grand finale.
Part II Mocking like a Pro
Part III Unit Testing with async/await
Part V (interlude) - Implement your Data Access Layer with Combine
Part VI Combine, async/await, and Unit Testing
Part V: (interlude) - Implement your Data Access Layer with Combine
Apple released Combine in 2019. The framework underpins much of the reactive nature of SwiftUI, but is also a powerful tool in its own right. We're going to take a break from testing in this chapter, and I'm going to get us all up to the same level of understanding in preparation for Part VI - Combine, async/await, and Unit Testing.
Today, we're going to:
Explore the benefits of a data access layer.
Learn all about the Repository pattern.
Gain an introduction to the basics of the Combine framework.
Finally, we'll pull these concepts together to show you how you can implement your own data access layer with Combine.
The Data Access layer
Architecture Overview
Let's revisit our nifty architecture diagram for Bev from Part I - Dependency Injection Demystified. In our modular architecture, the key layers are:
UI / Presentation Layer - to display UI and handle events
Data Access Layer - to define what data we want to get
Network Layer - to deal with how we get this data
High level architecture diagram for Bev, showing the direction of data flow from events (i.e. user actions), down through the data access layer, to a network request and the wider internet, back up the layers, to update the UI with model data
Data access as an abstraction
The Data Access Layer is an abstraction to make life easy for the UI layer above it.
The interface of the data access layer could be as simple as this:
public protocol DataAccessLayer {
func getDataModels() async throws -> [Models]
}The data access layer is an interface that promises, among other things, to give you some data. The key is that it doesn't tell you how it is getting the data you want - it's on a need-to-know basis, and the consumer of the API doesn't need to know.
The innards of the Data Access Layer are non-public; the nuts and bolts are an implementation detail, which the UI layer (and, perhaps, your front-end developer) is freed from worrying about.
Implementation details
Inside the Data Access Layer, however, we do care about the implementation details for fetching the data. This is where it might get interesting - there are lots of ways we might want to retrieve data in an app:
Fetch from network
Makes HTTP request to internet
Very slow speed (0.01 to 10 seconds)
Fetch from local persistence
Reads from disk
Medium speed (0.1 to 1 milliseconds)
Fetch from local cache
Reads from RAM
Very fast speed (10 to 100 nanoseconds)
Now we can realise the beauty of a Data Access Layer - we can use any and all of these approaches to fetching data, and synthesise a result which gives the consumer of our API the most up-to-date data, as quickly as possible.
We might first check our local cache for the data we want, to see if we can deliver it instantly. If it's not already here, we check our local persistence. Finally, as a last resort, we can fetch data from the network. While it's by far the slowest method, an HTTP call ensures the data returned to the user is up-to-date. We can then persist and cache the data for fast retrieval later.
Retrieval strategies
We can even perform all these fetches simultaneously and generate a single result to return to the UI layer. We can update our interface to allow consumers to define a retrieval strategy such as:
Returning any data as quickly as possible
Returning only the most up-to-date data from the network, but using local storage as a fallback
Returning data as fast as possible, but potentially returning more than once (e.g. returning data from the cache, then from the network once it returns)
Here's a basic approach for implementing the interface with this strategy:
public enum DataAccessStrategy {
case fastestAvailable
case upToDateWithFallback
case returnMultipleTimes
}
public protocol DataAccessLayer {
func getDataModels(strategy: DataAccessStrategy) async throws -> [Models]
}The Repository pattern
The Repository pattern is an approach for implementing a Data Access Layer in your app. The core idea is to manage data access logic in a centralised location. This means a Repository can collect together multiple data access layers with different underlying implementations - for instance, we might fetch from an in-memory cache, a persistence layer, or over the network.
The resulting abstraction allows our Repository interface to return an approximation to in-memory objects, which your UI layer can handle with no trouble at all.
This is only an approximation because while we would love to instantly return in-memory objects every time, sometimes things take longer than expected or go wrong.
Our interface - the API contract we are promising consumers - is hence marked
async throws.asyncensures the worst-case scenario for retrieval speed over the network is accounted for, allowing the Swift runtime to suspend execution at the call site, andthrowsensures that consumers know they should plan to handle potential errors.
In this example we will implement both an in-memory cache and a network store, but perhaps I'll upgrade this with a persistence layer and retrieval strategies once I start digging into SwiftData.
Implementing our Repository with Combine
Combine - what you need to know
Combine is a functional reactive programming framework released by Apple in 2019; which provides a declarative API for processing values asynchronously. Much of SwiftUI uses the Combine framework as an implementation detail, most notably @Published properties you put in your view models.
To avoid massively increasing the scope of this already quite unwieldy 6-parter, read this SwiftLee article for an overview of the basics.
The primary concept we're going to use is CurrentValueSubject, which does two things:
1. Broadcasts a notification to all its subscribers when its value is updated (just like it's sibling, the PassthroughSubject)
2. Stores the most recently broadcast value.
You might already start to see how we could utilise this - #2 gives us an in-memory cache out-of-the-box!



