Most of the iOS applications are often a thin client that provides end-user frontend of your business logic implemented on a centralized, remote server. Thus, prior to implementing core features of your iOS app, iOS developer have to (1) setup communication with a backend server (often as REST requests), and (2) implement persistent storage to keep received data locally.
In this article, we will demonstrate a solution to store our model objects in a storage with a clear separation of concerns using protocol-oriented programming (POP). We will show how to create generic and loosely coupled storage module that provides seamless migration to the alternative framework implementation in the future.
Table of Contents:
5. Migrating to other storage?
Goal
The software architecture, according to Clements et al is defined as:
Good architecture separates software elements into disjoint and compressive parts. In this article, our goal is to represent a process of saving data to a storage into an abstract and coherent module. Thanks to this separation we can completely hide implementation details from the other layers (depending on your choice it can be: MVC, MVVM, VIPER or any other approach) and let application depend only on abstraction representation of your model entities.
Below you can find a general overview of the problem:
To achieve separation in this particular example, we require that presentation layer should be unaware of data origin, i.e. it should not know if it comes from a storage cache, backend server and which frameworks we decided to use underneath. All transportation details are hidden behind our abstraction.
Let’s use Swift’s structs and enums to model our state in a safe way. If you wish to read why do value types suit perfectly for model purposes, we recommend reading John Sundell’s article about it. TLDR: we want to limit number of state combinations so there will be less scenarios to handle and test. For example, if our backend returns some property a string value with already defined 3 possibilities, there is no reason to pass this raw string everywhere in the app and try to parse it in the last moment when presenting corresponding UITableViewCell. It is better to quickly convert it into an enum and later pass only this instance. Exactly the same benefit we gain from optionals: we tend to get rid of optionals as close to the source of optionality (e.g. free-form JSON) and do not let it spread around entire application (Soroush post elaborates on it).
Let’s come back to our problem: assuming that have already translated JSON into enums and/or structs (e.g. using Swift 4 Codable feature) we face an obstacle: most popular storages do not support them. For instance, CoreData and Realm require that entities that we wish to store inherit from NSManagedObject or Object classes, respectively. In this article, we will address this problem in a generic solution.
Implementation
For two incompatible worlds (value types <-> storage instance), we need a converter between them. Let’s define a protocol that marks that given state’s type is “translatable” into some generic storage instance (e.g. subclass of NSManagedObject) with all required implementations to handle conversion named Migratable. It is expected that our structs and/or enums that we wish to store in a storage will conform to this protocol
protocol Migratable: Equatable {
associatedtype Counterpart
associatedtype Context
static func build(from: Counterpart) -> Self?
func export(toContext:Context) throws -> Counterpart
static func delete(counterpart:Counterpart, fromContext:Context) throws
}
In its definition, we have to introduce associated type Counterpart, the concrete type that represents our type in the storage world and Context of our storage (e.g. NSManagedObjectContext). Static function build provides an implementation to instantiate it from a storage entity. You might be wondering why do we stick to a static function rather than self-explanatory failable init initializer? The reason is very simple: if we were implementing init in the extension, we would lose memberwise initializer when applied to a struct and thus force the developer to write it manually. Obviously, this is definitely something that we wish to avoid in the first place. export function is a reverse action: builds an entity from a struct/enum. Last element, function delete, as the name suggests, removes an entity from a context. Notice that this type should conform to Equatable, we will come back to it in a moment.
OK, we have an abstraction for translation back and forth between struct/enum and storage entities, so we can finally implement a function(s) to operate on a storage using API based on structs/enums. One approach would introduce add and remove methods but let’s provide convenience API, where the developer can synchronize two collections: collection of expected structs that we wish to persist and a sequence of currently existing entities in a storage. This could be a bit misleading, but hopefully, an example will make it clear. To ensure that storage contains only two structs you call:
try [someStruct1, someStruct2].synchronize(entities: currentEntites, in: context)
Since backend usually returns us some collection of elements, it should be really easy to synchronize our local copy with up to date elements from a server. Thanks to this we don’t have to individually care which elements to remove or add, this happens automatically.
As we don’t know what kind of collection our backend returns we will leverage the beauty of protocol-oriented programming and define it on a generic Sequence protocol. Any collection, like Array or Set, conforms to this protocol so we gain all potential combinations for free:
extension Sequence where Element:Migratable{
@discardableResult
func synchronize<StorageType, EntitiesSequence:Sequence>(entities:EntitiesSequence, in context:Element.Context) throws -> SynchronizeResult<StorageType> where StorageType == Element.Counterpart, EntitiesSequence.Element == StorageType{
let (entitiesToRemove, elementsInStorage) = entities.reduce(([StorageType](), [Element]())) { (accumulator, entity) in
// 1.
guard let element = Element.build(from: entity) else {
return (accumulator.0 + [entity], accumulator.1)
}
// 2.
if self.contains(element){
return (accumulator.0, accumulator.1 + [element])
}
return (accumulator.0 + [entity], accumulator.1)
}
// 3.
try entitiesToRemove.forEach({try Element.delete(counterpart: $0, fromContext: context)})
// 4.
let elementsToAdd = reduce([Element]()) { (prev, element) in
if !elementsInStorage.contains(element) {
return prev + [element]
}
return prev
}
// 5.
let added = try elementsToAdd.map { (mig) in
return try mig.export(toContext: context)
}
// 6.
return SynchronizeResult<StorageType>(added: added, removed: entitiesToRemove)
}
}
struct SynchronizeResult<ResultType>{
let added:[ResultType]
let removed:[ResultType]
}
Let’s follow what happens there:
- Try to create a struct of our underlying storage entity. In case it fails, we select it to remove, most likely because of some stale representation in our storage
- Check if given struct exists in an expected collection and thus should remain in the storage. For this purpose, we leverage the Equatable protocol on Migratable protocol
- Remove all unneeded entities from a storage
- Iterate on all structs to see which are missing in a storage. BTW. another place where we depend on equatable nature of our struct...
- Export new entities to the storage
- Return a summary of a synchronization, struct SynchronizeResult with all entities that have been removed and added to the storage. This could be useful for API consumer but we let consumer to completely skip this summary. Therefore we decorate this function with @discardableResult attribute.
We wrote a lot of code, but most of the job is done and we have already set a solid foundation that we will extensively reuse later.
Real world example
Now let’s have a look how could conformance to a Migratable look like in a real-world example, let’s say CoreData. For the sake of this post, let’s consider simplest chat message struct that could contain some state. Here is how Message, MessageState and CoreData generated files could look like:
struct Message{
let text:String
let state:MessageState
}
enum MessageState{
case sending
case delivered
case read
}
// Auto-generated
extension MessageStateEntity {
@NSManaged public var state: Int16
}
extension MessageEntity {
@NSManaged public var text: String?
@NSManaged public var state: MessageStateEntity?
}
// Auto-generated
extension MessageStateEntity {
@NSManaged public var state: Int16
}
extension MessageEntity {
@NSManaged public var text: String?
@NSManaged public var state: MessageStateEntity?
}
So let’s define translations between Message and CoreData counterpart named MessageEntity in an extension that provides conformance to the Migratable protocol:
extension Message:Migratable{
static func build(from entity: MessageEntity) -> Message? {
guard let bodyText = entity.text else {
return nil
}
guard let entityState = entity.state, let entity = MessageState.build(from: entityState) else{
return nil
}
return Message(text: bodyText, state: entity)
}
func export(toContext context: NSManagedObjectContext) throws -> MessageEntity {
let entity = MessageEntity(context: context)
entity.text = text
entity.state = try state.export(toContext: context)
return entity
}
}
This implementation is really straightforward and you should easily follow it.
The same procedure we apply for MessageState with a small modification. This type is just a simple enum without any associated types, so in a persistence storage, we should have just at most 3 instances to correspond to 3 different cases. Therefore in export we first try to reuse already existing instance, rather than always create new entity:
extension MessageState:Migratable{
static func build(from entity: MessageStateEntity) -> MessageState? {
return MessageState(rawValue: Int(entity.state))
}
func export(toContext context: NSManagedObjectContext) throws -> MessageStateEntity {
let request:NSFetchRequest<MessageStateEntity> = MessageStateEntity.fetchRequest()
request.predicate = NSPredicate(format:"state == %d", [rawValue])
request.fetchLimit = 1
let allStates = try context.fetch(request)
if let existingState = allStates.first {
return existingState
}
let entity = MessageStateEntity(context: context)
entity.state = Int16(rawValue)
return entity
}
}
Maybe you are curious why didn’t we implement delete function yet? If you think about it, most implementations would be almost the same for most structs - just call delete(_:) on NSManagedObjectContext. Is it where protocol extension shines so let’s provide it with a generic protocol specialization:
extension Migratable where Context == NSManagedObjectContext, Counterpart:NSManagedObject{
static func delete(counterpart: Counterpart, fromContext context: Context) throws {
context.delete(counterpart)
}
}
Warning: Please keep in mind that MessageStateEntity entities could be purged from a storage if we try to synchronize some collection of MessageState. To overcome this, we should provide custom delete implementation for such “non-standard” entities that never removes the representation of an enum case. Otherwise, updating the message state from .sending to .delivered would get rid of .sending representation in our database.
extension Migratable where Context == NSManagedObjectContext, Counterpart:NSManagedObject{
static func delete(counterpart: Counterpart, fromContext context: Context) throws {
context.delete(counterpart)
}
}
Ufff, we reached the end and we can now finally synchronize our storage using structs collections within single line call:
do{
try apiMessage.synchronize(entities: messagesInContext, in: context)
try context.save()
}catch{
print ("Storage synchronization failed")
}
Extra additions
Would you like to go further and decorate our API with additional functions, let’s say add? It couldn’t be easier with POP:
extension Sequence where Element:Migratable{
func add(to context:Element.Context) throws -> SynchronizeResult where StorageType == Element.Counterpart{
return try synchronize(entities: [], in:context)
}
}
Migrating to other storage?
How about switching our database to another storage provider for some reason? What do we have to change then? I have great news: changes are minimal as we have to only change conformance to Migratable implementation (build and export) for all structs that we wish to store in our shiny new database, let’s say Realm. All other parts are unaffected as synchronize is defined on a completely generic type and the rest of our app depends only on framework-agnostic structs and enums.
Ability to easily switch into other dependency framework is a symptom of highly decoupled architecture that follows Dependency Inversion principle.
Summary
Support for protocol extensions and extensive type inference in Swift makes protocol-oriented programming a first-class citizen that moves modularization to the higher level. What are the main benefits from POP? By implementing some core functions for a particular type we gain clean and decoupled high-level API out-of-the-box. In our example, we only had to provide type conversions and entire manipulation on a storage is provided us for free.
In this article, we presented the technique to synchronize collections of structs and completely generic persistent storage objects (it could be NSManagedObjects or Realm’s Object). Most of our synchronization logic sits in a generic protocol extension and do not depend on any particular underlying framework. We demonstrated that thanks to protocol-oriented approach we achieved a solution that follows both Single Responsibility and Dependency Inversion principles.