Error Handling: Error Flow

Error Handling: Error Flow

·

9 min read

Hello, how’s it going? 👋 It’s time to finally write about the next part of the series “Error Handling” This time we gonna discuss what we can call “Error Flow”, how error propagates from one part, area, or layer of the code, into another Hence, creating… a “Flow” We gonna also think about types of errors, like what we saw in the last post, when we wrapped Apple’s canceled error, we were able to categorize this error into a type that can be ignored, and a couple of goodies on the way that we can make use of

What are we building?

Today, we are going to build a payment method selection screen, and like last time, we have a “Why” here, on why are we building a payment method screen

  1. Having various payment methods with different implementations can have different types of errors, and hence, different handlings
  2. The variations also allow us to think differently about how to display those methods since not all methods can be displayed the same
  3. Also, each type of method has its own way of business logic, which might result in different ways of handling errors per screen, so we're going to look into that as well
  4. Lastly, it’s a good opportunity to integrate with Apple Pay

And, here is a glimpse of the final screen (since I enjoy this part when watching a tutorial tbh 😃)

image.png

Note: We’ll be focusing only on fetching our methods, and handling Apple Pay’s states

A Refresher of Uncle Bob’s Clean Architecture

image.png So, basically speaking we’re going to have our architecture layered into 4 layers The View’s -> The Presentation’s -> The business’s -> The data’s

Or, something like this

image.png

Similar to how each layer is supposed to handle its responsibilities (in other words, the happy path), they also need to learn how they can handle their errors…

🤔 But what kind of errors should they handle?

💡 Let’s break this into two categories

  1. Layer-specific errors

  2. Types of errors in general

Types of errors

Let’s break this one into 2 categories.

  1. Layer-specific errors
(like Network errors where connectivity may be lost)
  2. General types of errors
(Like an error that can be ignored (cancelled error))

General Types of Errors

Ignorable Errors

Similar to the last post, we had Apple throwing us an error when the user cancels Apple Sign In

Recoverable Errors

For example, when a token expires, you can still recover from that backend error by refreshing your token first, then continue on with the original request

Unhandleable Errors

Which are kinds of errors that can not be handled in the current layer, but can be handled in another layer, for example, you can’t fetch data from a repository, but the business layer may choose to omit this data or reformulate the response (aka, work its way around it) so its no longer an error (more on that later in this post)

Default Errors

This is the last line of defense, the worst has happened, you can’t handle the error, and now we gonna need to tell the user there is something wrong

Layer-specific Errors

Some layers may have a set of common errors, like Datasources, the most common one would be, I couldn’t fetch the data (with mentioning a reason, optionally), while others require being more specific when throwing an error Like trying to check your profile, and whoops! no profile created

image.png

To Error or not to error?

The thing about layer-specific errors is, sometimes, an error would be treated as an error in some use cases, and sometimes it would be treated as a normal behavior without a need to make a fuss of it

🤔 Dude…?

Yep, bare with me, so for example, let’s say you’re trying to pay for something, and in the system, you can pay with your wallet, but guess what? You didn’t create a wallet for yourself in this system, so when paying, you can still pay with other methods and no need to tell you there is an error, right?

But, let’s say you’re visiting your wallet page for the first time, and there is no wallet, that’s when the error makes sense, where you don’t have a wallet error makes sense to be dealt with

Ok, too much reading, let’s start

Getting Started

So our happy scenario involves opening this screen and fetching our methods, and they all succeed, but we’re not here for happy scenarios, are we?

The Flow

The Data Layer

So, in the data layer, we got sources for these data, like the network & cache & databases, but for now, we are going to skip the sources, and talk directly about the repository layer

💡 Note: I talked about Network briefly here in the TDD series

The Wallet

protocol WalletRepositoryProtocol {
  func fetchWallet() async throws -> Wallet
}
  1. We expect a wallet (duhh)
  2. It can throw an error (yea yea, error handling series, what a surprise 🙄)
  3. It's async (cuz it takes time to call an imaginary wallet endpoint)

so, let's start by addressing our Error flow first

enum WalletRepositoryError: RepositoryError {
    case noWalletFound
    case banned
    case inReview(_ state: ReviewState)
}

extension WalletRepositoryError {
    enum ReviewState {
      case pending
      case beingProcessed
    }
}

So, there can be no wallet, it could be banned, or it could be in review state with some more info about where it is now in the process, so as a Repository, I need to know what errors that can happen, so it's possible for me to handle them or not, and it's up to the next layer (the caller), to decide if he's interested in the errors I couldn't handle or not

Let's peek into the UseCase for the payment methods

fileprivate func addWalletIfPossible(_ methods: inout [PaymentMethod]) async {
    if flags.wallet {
      do {
        methods.append(try await walletRepository.fetchWallet())
      } catch let error as WalletRepositoryError {
        LoggersManager.error(error)
      } catch {
        LoggersManager.error(message: "Unknown error caught: \(error)")
      }
    }
  }

So, the Usecase here is interested in knowing whether the Wallet succeeded or not, and in case of failure, it would just log the error without failing the whole request made by the user

💡 Note: Notice that in the above example, since we're providing the user with his payment methods only, failing to fetch the wallet here means that we're not returning it as a payment method, while in a different example like a Wallet page, there must be a wallet or an error to tell the user he needs to create a wallet, and that's where the extra details of the error come in handy

Apple Pay

Now, Apple Pay is a bit different than your normal, usecase - repo - network call, it's a service of its own, and wrapping it would help us a lot in terms of reusing it, and also define how to handle its errors

So in here, we're going to define 2 things, ApplePay as a Payment Method, and ApplePay Service, which is a wrapper around ApplePay's API itself

💡 Note: Aside from being able to testing, having a wrapper allows us to bridge its errors, allowing us to handle its failures even better isA

protocol ApplePayServiceProtocol {
    func fetchApplePay() throws -> ApplePayProtocol
}

enum ApplePayError: Error {
    case serviceNotAvailable
}

I believe this is what I need to handle from the ApplePayService for now, and all that is left is how the UI would deal with ApplePay, like if its status needsSetup or is it available for use, because later on, that would decide the flow if the user picked it as a payment method

func fetchApplePay() throws -> ApplePayProtocol {
        guard PKPaymentAuthorizationController.canMakePayments() else { throw ApplePayError.serviceNotAvailable }
        let isItSetup = PKPaymentAuthorizationController.canMakePayments(usingNetworks: supportedNetworks)
        return ApplePay(status: isItSetup ? .needsSetup : .available)
    }

So, now that our repositories & services has been defined, let's jump into the business layer, and see how these 2 layers would interact with each other, BUT, first let's check where we are on the diagrams so it's easy to follow along.

The Domain

image

Now that an error made it into the domain layer (the use case), it's up to this class to decide what to do with it

🤭 And no, fortunately we're not bounding anyone's soul here

But, let's first think about what do we need, we have 3 sources for payment methods

  1. The Wallet
  2. The Payment Cards
  3. Apple Pay

So, we're going to depend on these 3 sources in our UseCase and its our job there to decide what to do if they fail

override func process() async throws -> [PaymentMethod] {
    var methods: [PaymentMethod] = []

    await addWalletIfPossible(to: &methods)
    await addPaymentCardsIfPossible(to: &methods)
    await addApplePayIfPossible(to: &methods)

    guard !methods.isEmpty else { throw PaymentUsecaseError.noPaymentMethodsFound }

    return methods
  }

So, we're basically adding our methods if possible, but we're not really interested in failing the request unless its empty

🤔 Can't we return an empty array & be done with it?

💡 We can, there is no shame in it, but there are 4 reasons for this

  1. similarly to the backend response when you're asking for an array of resources, the better practice is to send a 404, not a 200 and an empty array
  2. similar to real life, if you're asking your local shopkeeper for some Avocados, and there is no avocados, he won't give you an empty bag, he would tell you they're not there
  3. I believe being explict is key, so instead of returning empty array or a false boolean to indicate that you couldn't find or do something, it's better to throw an error where you can give more details (Check Uncle Bob's Chapter 7: Error Handling)
  4. This is an Error Handling series, so we're trying to make a point here

Now, since our Usecase has its goal sorted out 👏, it's time to move to...

The Presentation

So, in the view model, we're interested in couple of things, having our State defined in a way that makes effort for the View minimal to display

  struct State {
    var wallet: WalletPaymentMethodUIModel?
    var paymentCards: [PaymentCardMethodUIModel] = []
    var applePay: ApplePayPaymentMethodUIModel?

    var isEmpty: Bool = true

    var error: PresentationError?
    var message: SuccessMessage?
  }

It allows the view to ask for Payment methods

func fetchPaymentMethods() {
    Task {
        do {
            updateState(try await PaymentUsecase().execute())
        } catch { ... }
    }
}

And lastly, it handles the errors and updates the state accordingly

func fetchPaymentMethods() {
    ...
    do { ... } 
    catch PaymentUsecaseError.noPaymentMethodsFound {
        state.isEmpty = true
    } catch is UnknownBusinessError {
        state.error = .default
    } catch {
        state.error = .default
    }
}

Now that our state is configured, the flow should move to the view CleanShot 2022-10-02 at 14 50 40@2x

By the time any errors make it past the Presentation, the view can do minimal effort to display it, telling our users exactly what went wrong, but what if that error demanded us to change our current flow? for example, what if the user is no longer authenticated and must re-login?

That's a question for our next blog post, Error Handlers!

Conclusion

We now know that Error Handling is not all about some do catch blocks, sometimes it takes more effort and more design on paper to learn how to deliver something mature enough to help in delivering the best experience to the user, and even when things go south, it's still possible to give a reason for that

And like always, feel free to check out that git repo that hosts the code to this proejct Error Flow Code