Imagine you’ve just implemented a nice feature. Cleanly separated UI from business logic, added some unit tests, etc. Surely, code review would be a formality. Instead, this insufferable Senior Dev requested that you wrap one of the services with abstraction. Surely, you’ve read somewhere that you should operate on abstractions instead of actual implementations, but really…
Why must we “attach” protocols everywhere? After all, whenever we change the API, we need to do it in at least 2 places now: in the implementation(s) and in the protocol. Is it really worth the effort? What benefits do abstractions bring to the table to justify this additional “housekeeping”? What requirements must an abstraction fulfill to be considered a good one? Strap in and let’s find out!
A story of a (not so) simple storage
Let’s take a look at a, seemingly, very simple task: ensuring that some user data is persisted between app launches. I bet each of us implemented such a feature countless times. All we need to do is to make sure the data we wish to store conform to Codable protocol, encode them using JSON encoder, and write to UserDefaults storage. To retrieve the data we simply need to reverse these steps. And if we are in a particularly good mood, we can wrap the entire procedure into a property wrapper to make it easier to use.
For an experienced developer, the entire task should take no more than 1-2 hours. Surely, no need for abstraction here… But is it really so? Are you absolutely sure UserDefaults is a good place to store sensitive data? You might argue that no security requirements were set, so you chose the simplest on-device storage available. And, honestly, you’d be absolutely right. There is no point in over-engineering. We should keep our solutions simple.
Unfortunately, project requirements have one nasty flaw – they change constantly. Paradoxically, this is the only constant thing about them… Stakeholders come and go, company policies change, etc. We cannot simply refuse to implement a requested change just because the initial requirements did not include it.
Coming back to our task, the business requested the app to be pentested. Naturally, one of their first findings was insufficient security of locally stored, sensitive data. An obvious suggestion was to move that data to the Keychain. Preferably, a biometrically protected one. A “standard” Keychain should be relatively easy to integrate, as it offers a synchronous API. But the biometric storage is, by definition, asynchronous – we have to wait for the requested data until the user confirms their identity. And if the verification fails? We’ll get no data at all. There goes our beautiful property wrapper…
Whatever the new solution, we’d have to replace the current one across the app.
I want to die…
Is there a better way? Yessir
What if I told you that there is a way to make your application agnostic to whatever local storage solution you’ll ultimately choose? A simple trick that can allow you to delay making such a decision until all (or at least most) of the project requirements are known?
Indeed, there is such a trick – it’s called abstraction. Let’s try to create one for our storage.
So, what is an app local storage responsible for? That’s easy: storing and retrieving data. Let’s write this down:
protocol LocalStorage {
func setValue<T: Encodable>(_ value: T, forKey key: String) throws
func getValue<T: Decodable>(forKey key: String) -> T?
func removeValue(forKey key: String) throws
}
Thanks to generics and Codable protocol, we can create an universal set of methods to store, retrieve and clear virtually every type of data.
But thinking of it, does every storage allow for synchronous data access? Forgot about the biometric Keychain already? Let’s update our protocol:
protocol LocalStorage {
func setValue<T: Encodable>(_ value: T, forKey key: String) async throws
func getValue<T: Decodable>(forKey key: String) async throws -> T?
func removeValue(forKey key: String) async throws
}
Now, let’s get down to business and implement our storage using User Defaults:
extension UserDefaults: LocalStorage {
func setValue<T: Encodable>(_ value: T, forKey key: String) async throws {
guard let encoded = try? JSONEncoder().encode(value) else {
throw StorageError.unableToEncodeData
}
set(encoded, forKey: key)
}
func getValue<T: Decodable>(forKey key: String) async throws -> T? {
guard let data = data(forKey: key) else {
return nil
}
return try JSONDecoder().decode(T.self, from: data)
}
func removeValue(forKey key: String) async throws {
removeObject(forKey: key)
}
}
It seems a bit excessive at first, but storing and retrieving data from any kind of local persistence is an operation that takes time. So it should be asynchronous by definition.
Finally, we can inject the storage wrapped in the abstraction into our business logic and start using it:
final class LiveMessagesViewModel: MessagesViewModel {
…
private let storage: LocalStorage
init(
storage: LocalStorage = LocalStorageFactory.makeLocalStorage()
) {
self.storage = storage
…
}
}
…
enum LocalStorageFactory {
static func makeLocalStorage() -> LocalStorage {
UserDefaults(suiteName: "com.swiftwithmemes.myApp") ?? UserDefaults.standard
}
}
Ok, looks good but how does it fare in real-life situations? What if we needed to bump local storage security, ditching User Defaults?
First, we need to implement a component that operates on e.g., the biometric Keychain and conforms to our abstraction:
final class KeychainStorage: LocalStorage {
private let keychain: Keychain
init(keychain: Keychain = .genericKeychain) {
self.keychain = keychain
}
func setValue<T: Encodable>(_ value: T, forKey key: String) async throws {
guard let encoded = try? JSONEncoder().encode(value) else {
throw StorageError.unableToEncodeData
}
do {
try keychain.set(encoded, key: key)
} catch {
throw StorageError.dataStorageError
}
}
/// - SeeAlso: LocalStorage.getValue(forKey:)
func getValue<T: Decodable>(forKey key: String) async throws -> T? {
guard let data = try keychain.getData(key) else {
return nil
}
return try JSONDecoder().decode(T.self, from: data)
}
func removeValue(forKey key: String) async throws {
do {
try keychain.remove(key)
} catch {
throw StorageError.dataStorageError
}
}
}
private extension KeychainStorage {
static let service = "com.example.FirebasePOC"
}
extension Keychain {
static var genericKeychain: Keychain {
Keychain(service: KeychainStorage.service)
.accessibility(.afterFirstUnlock)
}
static var biometricKeychain: Keychain {
Keychain(service: KeychainStorage.service)
.accessibility(.afterFirstUnlock, authenticationPolicy: [.biometryAny])
.authenticationPrompt("Authenticate to access your data")
}
}
Next we modify arguably the only place in the app where our LocalStorage implementation is initialised – the factory:
enum LocalStorageFactory {
static func makeLocalStorage() -> LocalStorage {
KeychainStorage()
}
}
And… that’s it? Yes!
Unless we change the API that our abstraction exposes to the “outside world”, the consumers would not even notice that they are “talking” to a totally different implementation than before. It’s also arguably the simplest demonstration of one of the S.O.L.I.D. principles – the Dependency Inversion. By wrapping a dependency with an abstraction, we make any consumer rely on that abstraction – a set of behaviours and properties that can be accessed. And how exactly this contract is fulfilled “under the hood”? What means of storage are utilised? Who cares! It certainly does not matter from a high-level business logic perspective. E.g. a ViewModel does not care if it gets the data it needs from UserDefaults of the Keychain. And we rely on this principle in real life as well. We don’t need to know the details of the USB communication protocol as long as we know how to connect a compatible device correctly
The only thing worth taking a pause is the API that our abstraction exposes. Although it’s impossible to prevent it from changing, it’s best to make it as generic as possible. One downside of using abstractions is the fact that they can break the application if changed too often. Just think of it: what consequences a switch from synchronous to asynchronous data access API would have on the rest of the application? Yes… Fortunately, for commonly used services like storage and networking, it’s relatively easy to design a good abstraction. For more specialised ones, being used less across the app, you’ll probably have a luxury to make adjustments as you go.
Additional bonus: “clean” tests
How many hours did you spend investigating why your tests failed on the CI, whilst passing locally? Most often than not, there was an underlying, local state of the application that allowed the tests to pass. On the CI however, the application was installed cleanly and had no access to such data. It’s especially prominent when testing components using local storage as dependencies.
Having defined a proper abstraction, we can transform such flaky test cases into “clean” ones. The tested component no longer has to rely on the actual implementation of its dependencies. As wise and erudite software engineers would say: it’s implementation-agnostic. In other words: from this component’s “point of view”, it’s irrelevant what implementation of a given dependency we provide. Of course, as long as the dependency fulfils the contract defined by the abstraction.
Armed with this knowledge, let’s define a FakeStorage to mock the real one:
final class FakeLocalStorage: LocalStorage {
private(set) var lastSetValue: Any?
private(set) var lastSetKey: String?
private(set) var lastRemoveKey: String?
var simulatedValue: Any?
func setValue<T: Encodable>(_ value: T, forKey key: String) async throws {
lastSetValue = value
lastSetKey = key
}
func getValue<T: Decodable>(forKey key: String) async -> T? {
simulatedValue as? T
}
func removeValue(forKey key: String) async throws {
lastRemoveKey = key
}
}
As you can see, the FakeStorage allows us to simulate stored data and record which data was set or removed from it. Now, let’s use it in our tests:
final class LiveFirebaseTopicsSubscriptionManagerTest: XCTestCase {
var fakeLocalStorage: FakeLocalStorage!
var fakeFirebaseTopicsSubscriber: FakeFirebaseTopicsSubscriber!
var sut: LiveFirebaseTopicsSubscriptionManager!
override func setUp() {
fakeLocalStorage = FakeLocalStorage()
fakeFirebaseTopicsSubscriber = FakeFirebaseTopicsSubscriber()
sut = LiveFirebaseTopicsSubscriptionManager(
subscriber: fakeFirebaseTopicsSubscriber,
storage: fakeLocalStorage
)
}
func test_whenSubscribingToTopic_shouldStoreTopicNameInLocalStorage() async {
// given:
let fixtureTopicName = "fixtureTopicName"
// when:
let result = await sut.subscribe(to: fixtureTopicName)
// then:
XCTAssertEqual(fakeFirebaseTopicsSubscriber.lastSubscribedTopic, fixtureTopicName, "Should subscribe to topic")
XCTAssertEqual(fakeLocalStorage.lastSetValue as? [String], [fixtureTopicName], "Should store topic name in local storage")
XCTAssertEqual(fakeLocalStorage.lastSetKey, StorageKeys.subscriptionTopics.rawValue, "Should store topic under proper key")
XCTAssertTrue(result, "Should succeed in all operations")
}
}
And just like that, we gained full control over the testing environment. We can easily simulate all edge cases in regards to storage: clean app installation (totally empty storage), incomplete data, etc. And when the tests are executed, we don’t need to ensure restoring UserDefaults, Keychain, etc. to its original state, as the entire “storage” took place in memory.
Summary
To sum up: the only constant in our projects is… change. If the product we work on is to survive on the market, it must evolve all the time. One way or another, you’ll most likely be forced to replace most of the code you wrote for the MVP. And that’s a good thing! It means that e.g. new or improved SwiftUI features were introduced so you could make your app better. Or that the app reached massive adoption and initial solutions are simply not good anymore. An ability to design app architecture in the way that allows implementing these changes quickly and painlessly can decide if the application succeeds or not.
As an alternative, imagine being stuck in the project that still uses O.G., iOS 13 SwiftUI navigation. Unless you’re an archeologist, you might prefer a more up-to-date solution like the Navigation Stack. The same logic applies to the local storage solution, networking client, etc. If such a component is represented by a proper abstraction, we can replace it with a completely different implementation. And the rest of the application won’t even notice.
Finally, let’s look at project requirements again. Have you ever started a project with a complete, exhaustive list of functionalities? And even if you did (which I doubt…), how long did it take before these requirements were changed? The bottom line is that we cannot (and should not) assume we’ll know all the requirements before the project starts. More often than not, we discover these requirements as we keep releasing new versions of the app – by getting user feedback. Thanks to right abstractions we can keep delaying making critical decisions, like choosing a local storage, until we know most of the associated requirements. Until we do, however, we can start with a simple implementation, knowing full well that it’ll have to be replaced sooner or later.
Yes, it may seem a waste of time, but…
Don’t miss any new blogposts!
By subscribing, you agree with our privacy policy and our terms and conditions.
Disclaimer: All memes used in the post were made for fun and educational purposes only
They were not meant to offend anyone
All copyrights belong to their respective owners
The thumbnail was generated with text2image
Some images were generated using Leonardo AI
The post Why mobile apps need a good abstraction? first appeared on Swift and Memes.