DEV Community

Cover image for Swiftly Awaiting Async Code Using Appwrite
Jake Barnby for Appwrite

Posted on

Swiftly Awaiting Async Code Using Appwrite

Modern application development with Swift involves a lot of asynchronous (or "async") programming using closures and completion handlers, but these APIs are hard to use. This gets particularly problematic when many asynchronous operations are used, error handling is required, or control flow between asynchronous calls is required. Using the new concurrency model makes this a lot more natural and less error-prone.

With the Appwrite 0.14 release, the Apple and Swift SDKs have been updated with first-class support for async/await. To understand why this is important, and see how to implement the changes, let's make a comparison between the old and new APIs.

🏚️ The Old Way

Async programming with explicit callbacks (also referred to as closures or completion handlers) has many problems. The most obvious issue is that they make code more difficult to read and maintain. They also make it hard to write testable code. Let's take a look at how some of those problems manifest:

Problem 1: Pyramid of Doom

A sequence of simple asynchronous operations often requires deeply-nested closures:

func getUserProfileImage(userId: String, completion: (Result<ByteBuffer, AppwriteError>) -> Void) {
    let database = Database(self.client)
    let storage = Storage(self.client)

    database.getDocument(collectionId: "profiles", userId: userId) { docResult in
        switch docResult {
        case .success(let document):
            storage.getFileDownload(bucketId: "profiles", fileId: document.data["profileImageId"]) { fileResult in
                switch result {
                case .success(let bytes):
                    completion(.success(bytes))
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This "pyramid of doom" makes it difficult to read and keep track of where the code is running. In addition, having to use a stack of closures leads to many second-order effects that we will discuss next.

Problem 2: Error Handling

Looking at the above example - we are only handling success cases. What if the user doesn't exist? What if the user has no profile image? Callbacks make error handling difficult and very verbose:

func getUserProfileImage(userId: String, completion: (Result<ByteBuffer, AppwriteError>) -> Void) {
    let database = Database(self.client)
    let storage = Storage(self.client)

    database.getDocument(collectionId: "profiles", userId: userId) { docResult in
        switch docResult {
        case .success(let document):
            storage.getFileDownload(bucketId: "profiles", fileId: document.data["profileImageId"]) { fileResult in
                switch result {
                case .success(let bytes):
                    completion(.success(bytes))
                case .failure(let error):
                    completion(.failure(error))
                }
            }
        case .failure(let error):
            completion(docResult)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Problem 3: Missing completion calls or return statements

It's easy to bail-out of the asynchronous operation early by simply returning without calling the completion block. When forgotten, this issue can be very hard to debug:

func getUserProfileImage(userId: String, completion: (Result<ByteBuffer, AppwriteError>) -> Void) {
    let database = Database(self.client)
    let storage = Storage(self.client)

    database.getDocument(collectionId: "profiles", userId: userId) { docResult in
        switch docResult {
        case .success(let document):
            // ...
        case .failure(let error):
            return // <- Bail out early
        }
    }
}      
Enter fullscreen mode Exit fullscreen mode

Now if you call getUserProfileImage and an error occurs, the function will never complete and the call site code will be deadlocked. Even when you do remember to call the block, you can still forget to return after that:

func getUserProfileImage(userId: String, completion: (Result<ByteBuffer, AppwriteError>) -> Void) {
    let database = Database(self.client)
    let storage = Storage(self.client)

    database.getDocument(collectionId: "profiles", userId: userId) { docResult in
        switch docResult {
        case .success(let document):
            // ...
        case .failure(let error):
            completion(.failure(error)) // <- No return, execution continues
        }

        doSomethingElse(userId) // <- Executes on error
    }
} 
Enter fullscreen mode Exit fullscreen mode

✨ The New Way

Asynchronous functions (async/await) allow asynchronous code to be written as if it were synchronous code. This immediately addresses all the problems described above by allowing programmers to make full use of the same language constructs that are available to synchronous code.

Solution 1: No More Pyramid of Doom

The control flow simply reads from the top down, with no nesting:

func getUserProfileImage(userId: String) async throws -> ByteBuffer {
    let database = Database(self.client)
    let storage = Storage(self.client)
    let document = try await database.getDocument(
        collectionId: "profiles", 
        userId: userId
    )
    let bytes = try await storage.getFileDownload(
        bucketId: "profiles", 
        fileId: document.data["profileImageId"]
    )
    return bytes
}
Enter fullscreen mode Exit fullscreen mode

As you can see, this code is much more readable, concise and easier to reason about, while performing the same operations as before.

Solution 2: Do/catch error handling

Errors can be handled using the do/catch syntax, the same way as synchronous code:

func getUserProfileImage(userId: String) async throws -> ByteBuffer {
    let database = Database(self.client)
    let storage = Storage(self.client)

    do {
        let document = try await database.getDocument(
            collectionId: "profiles", 
            userId: userId
        )
        let bytes = try await storage.getFileDownload(
            bucketId: "profiles", 
            fileId: document.data["profileImageId"]
        )
    } catch {
        throw error
    }

    return bytes
}    
Enter fullscreen mode Exit fullscreen mode

Solution 3: Completions and nested returns are gone completely

The solution to the third problem is to now... do nothing. We no longer have to worry about missing completion handler calls or nested returns using async/await code.

🔮 Future Possibilities

Async code has the potential to change how we develop apps with Swift. Overall, code will become much more readable and robust, with less effort required to implement (increasingly) common async patterns. I am excited to see what developers can create with async/await.

📚 Resources

You can use the following resources to learn more and get help.

Discussion (0)