Originally written on 2022-02-19
Last updated on 2023-05-06

Overview

For the past few years, I've been working on some library code alongside application code for almost all of my projects. Most times, this library code is consumed by more than one app.

I recently started refactoring the main entry point to an SDK I've been working on. The SDK has specific responsibilities but, more importantly, it requires an order of operations.

Invalid State

Let's start with this configuration type.

It contains two (2) important pieces of information:

  1. an idToken allowing the SDK to authenticate the user
  2. the base URL of the server the SDK must use

Let's continue with this authentication type.

It contains an accessToken to authenticate requests and a refreshToken to get a new access token should the existing one expire.

Let's finish with this client type.

This client is the entry point to the SDK; the app must call configure(with:) before it can call start() or preloadMedia().

You might have already picked up on the issues here; we have modeled invalid state and we have to defensively code to work around it. Additionally, we're forcing the app to defend against invalid state as well.

Application Consumption

A consuming application would have to use our design in this way:

As an SDK consumer myself, I dislike this situation where the code isn't intuitive enough and forces me to search through documentation to get this right.

The Issues

Optionality

We require these instances in order for start() and preloadMedia() to be called, but we can't make them non-optional because:

  1. we don't have these values at initialization time where we could make use of constructor injection
  2. providing default values for them really do not make any sense

Their optionality also raises another question: is it valid to have one but not the other? It's not, but this code certainly makes this possible.

Defensively Throwing

Every method other than configure(with:) needs to ensure these two (2) properties are set before continuing. What happens if we forget to check for these properties in our newly written method? We could be lucky where the app has called configure(with:) first but we cannot ensure it without these checks.

Furthermore, the start() and preload() methods are forced to be throwing even if their operations aren't actually throwing. I suppose we could make use of fatalError() instead and remove the throws from the signatures but that wouldn't be a stellar developer experience when consuming the code.

There's a better way

We can remove all invalid state by making use of return values. Consider this new approach:

The Client class is significantly simplified and can only perform the operations it should be able to. Additionally, it no longer has invalid state while exposing the same functionality.

This new AuthenticatedScope class provides the exactly the same functionality as before. However, we were able to eliminate invalid state by using contructor injection. We are also able to remove the guard statements and also remove the throwing functionality.

This new design allows for a slightly simpler series of calls. More importantly, and subtly, we've completely eliminated the ability to call operations in the wrong order. If the app needs to call preloadMedia() it can only do so by calling configure(with:) first.

Conclusion

I've written my fair share of invalid states over the years. I've been really focusing on removing these potential headaches from all the code I'm writing nowadays. How would you have refactored the original design?

Don't hesitate to reach out, tweet, slack or discord me.

🔥 Putting more logs in the fireplace. Go tell someone you love them.

Tagged with: