Error Handling: Defined Errors

Error Handling: Defined Errors

Β·

5 min read

Back at last!

Hello, Folks!
Finally, this series is seeing its ending so I can move to other series!

πŸ˜Άβ€πŸŒ«
I can hear you, you in the back, mentioning my lack of discipline, and dear kind sir, you're so spot on! 🫑

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
No

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

  1. The method itself (Prime Suspect)

  2. The invokers (Victims)

  3. Expectations between layers (Witnesses)

πŸ€·β€β™‚
After all, fetching happens in the Data layer, so you, the business layer may be expecting some fault scenarios, and given you sent an error that is undefined to it, the app goes into an invalid state like...

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'

😞
Which sometimes end up with 'something went wrong in the end' and a frustrated user

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...

Mendokusai What A Drag GIF - Mendokusai What A Drag ...

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 πŸ‘‡

32 Brooklyn Nine-Nine Memes & Moments For The Superfans - Memebase - Funny  Memes

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...

πŸ’‘
But the trade-off here is that we have better visibility and control over the errors we have to handle, more over, we are able to limit the amount of modifications

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 πŸ‘

Β