Back at last!
Hello, Folks!
Finally, this series is seeing its ending so I can move to other series!
Well... you might have noticed that async-await does indeed make code much more readable, and easier to handle. and the fact of allowing the compiler to help you define Race Conditions and Non-Thread-safe code via 'Sendable', Nonsendable, Actors, and Structured Concurrency... makes adopting async-await a very common goal for many tech teams these days... and today is a lesson I wanted to share with you what I've learned recently...
but before we start with our learning, I just wanna say...
Alright, Detectives, I've just binged-watched 6 seasons of Brooklyn 99, and today am Jake Peralta, and today, we are investigating...
Defined Errors and how they can screw your design... title of your bug report
Problem?
The throws
keyword in this statement
func fetchRecentWalletTransactions() async throws -> [Transaction] { ... }
Now, don't get me wrong; after all, this is not an async-await series, nor am I asking you to ignore errors (it's an Error Handling Series after all π¬)
But as innocent, and tiny and so inline with the best practices of Clean Code & Swift Documentation, this keyword, can do you bad in terms of software design and maintainability of your layers...
Why is it a problem?
How many errors can I throw from this method alone?
enum WalletTransactionFetchError: Error {
case walletAccountNotSetup
case walletUnavailable(reason: String)
case walletOnHold
case noTransactions
}
Well, Yep, seems like a good start... but is that all?
Spoiler
You can throw anything that conforms to Error, and for now, that's ok, or so it seemed
Due to not being specific, we lost being specific about what's going wrong, and supplying to our dependents what actually happened, so they have to do some guesswork, and that's where we lost the way, so now we're here, in the crime scene (the codebase), and trying to identify our characters...
The Crime Scene
The method itself (Prime Suspect)
The invokers (Victims)
Expectations between layers (Witnesses)
Suspect: Method Implementation
Mostly here, there won't be much trouble found..., upon fetching, you found that there is an issue, and you begin to map it to the above enum cases, however, worst case scenario, you can say, .walletUnavailable
with a reason, right?
Victim: Invoker
Like any RPG or a story with multiple protagonists, each has their own side of the story, and let me tell you something, the Invoker is not HAPPY, about it...
I mean, you had one job! Fetching the transactions...
class TransactionWidgetViewModel {
...
func onAppear() async {
do {
async try transactionsUsecase.fetchRecentWalletTransactions()
} catch let error WalletTransactionFetchError {
// some handling
} catch {
// Default handling
}
}
}
Now, this went quickly from 'handling the fetch error', to 'guess what broke'
The problem here is... if there are multiple error types, that means the method we built above may be doing two things or more... which may be a sign of SRP violation...
Similarly, the default handling is another place we would like to avoid...
because if our code reached this point, that means there is an invalid state, the application is currently in, and to quickly recover from it, we map it into a something went wrong error...
Lastly, if we were to handle each case, and if there is a new case that popped up which require its own handling, we might also break the OCP, which is another word for a...
Witnesses: Expectations
Remember the Error Flow episode?
We were mentioning how each layer would expect an error of some sort from its dependency, or layer... but now that the only thing we get is any Error
, we gotta try to see if its the error we're expecting, or just wrap it inside a 'something went wrong', or an object with more metadata to describe it better later on (or not... since its a bit risky and some guess-work is involved)
so... in a nutshell, if we were to summarize all the above in one image... this one does a pretty good job at it π
Solving the case: A Fix
Alright, now that we have an idea of what's wrong, let's check an approach that addresses it
func fetchRecentWalletTransactions() async -> Result<[Transaction], WalletTransactionFetchError> { ... }
A simple, non-fancy, and weird-looking solution would be to return to Result<Success, Failure>
The reason for this is I can specify why this method can fail and the scenarios that follow it up... so the invokers can react accordingly
The Invoker's Story
func onAppear() async {
let result = async transactionUsecase.fetchRecentWalletTransactions()
switch result {
case let .success(...):
...
case let .failure(error):
handle(fetchError: error)
}
}
func handle(fetchError: WalletTransactionFetchError) { ... }
It seems kinda familiar to how we used to handle errors when we depended on callbacks, and the syntax was kinda boring...
Conclusion
Now that we have learned Defined Errors and their importance, It's high time I start working on that blog about monitoring the errors and how important this is as a practice π