Managing objects using Locks and Keys in Swift
One of the most important roles of any software architecture is to make the relationships between the various objects and values within an application as clear and well-defined as possible. However, maintaining those relationships - however thoughtfully designed - can be really challenging, especially over time.
As a code base grows and new objects are introduced, it's easy for an app to accidentally end up in an undefined state - such as when a required value or object ended up being missing. While Swift offers many language features to help us avoid such situations - for example its strict, static type system, and concepts like optionals - it can be easier said than done to take full advantage of those features.
This week, let's take a look at how we can do that - and how we can use Swift's powerful type system to set up locks and keys that can help us avoid undefined states and get a stronger, compile-time guarantee that the intended flow of our app will remain intact at runtime.
Awkwardly missing objects
Let's start by taking a look at an example of what kind of problem that we're looking to solve. However well-organized, most applications need to deal with some form of global (or at least semi-global) state - some value or object that most of our application depends on in one way or another.
For example, our app might require the user to log in to get access to certain screens, and use some form of UserManager
singleton to keep track of whether a user is currently logged in or not - looking something like this:
class UserManager {
static let shared = UserManager()
private(set) var user: User?
func logIn(with credentials: LoginCredentials,
then handler: @escaping (Result<User>) -> Void) {
...
}
}
The above code may look straightforward, but problems start to arise as soon as we start using it within a part of our code base that requires a logged in user in order to work.
For example, let's say that we're building a view controller for displaying the currently logged in user's profile. Even though this view controller will only be used once the user has logged in, we only get access to UserManager.shared.user
as an optional, and need to unwrap that optional in order to actually use its values. Since there's no sensible fallback if that unwrapping fails, all we can really do is to trigger an assert, like this:
class ProfileViewController: UIViewController {
private lazy var nameLabel = UILabel()
private lazy var imageView = UIImageView()
override func viewDidLoad() {
super.viewDidLoad()
// This guard statement is kind of awkward, since we're
// ideally never going to enter the else clause.
guard let user = UserManager.shared.user else {
assertionFailure("Uhm...no user exists? 🤔")
return
}
nameLabel.text = user.name
imageView.image = user.photo
}
}
What we're essentially dealing with here is a non-optional optional - a value that's technically optional, but is actually required by our program logic - meaning that we risk ending up in an undefined state if it's missing, and the compiler has no way of helping us avoid that.
One piece of the puzzle is that we're accessing the currently logged in user through a singleton, rather than using dependency injection. If we instead were to properly inject the user as part of our view controller's initializer, we'd at least get rid of the problem locally:
class ProfileViewController: UIViewController {
private let user: User
init(user: User) {
self.user = user
super.init(nibName: nil, bundle: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
// We can now simply access our local user copy, rather
// than having to unwrap a singleton's optional.
nameLabel.text = user.name
imageView.image = user.photo
}
}
The above change is definitely a big step in the right direction - especially since we've also reduced our use of singletons and globally shared state. However, without any additional changes, we've simply moved that awkward assertion failure somewhere else - most likely to the piece of code that's responsible for creating our ProfileViewController
:
func makeProfileViewController() -> UIViewController {
// Still no compile-time guarantee that a user exists whenever
// we're displaying the profile screen :(
guard let user = UserManager.shared.user else {
assertionFailure("Whoops, no user 🤷♂️")
return UIViewController()
}
return ProfileViewController(user: user)
}
We could of course continue to move our guard let
statement somewhere else, but that seems kind of pointless. Instead, let's see if we can solve the core problem - which is that we're relying on a global optional value in order to make local decisions.
Locks and keys
What if we were able to hide the parts of our app that requires a user to be logged in behind some form of lock - where the user model ends up becoming the key that unlocks that lock? For example, in order to be able to create a ProfileViewController
in the first place, the caller would need to supply a non-optional user - making our above method simply look like this:
func makeProfileViewController(for user: User) -> UIViewController {
return ProfileViewController(user: user)
}
If we would be able to do the same for all other classes that require some specific data or state in order to work, then we'd end up with a lot less ambiguous code - and we'd be able to get rid of most of those awkward guard let
statements that we only really put in there in order to satisfy the compiler.
That might sound great in theory, but can be quite difficult to pull off in practice - so let's take a look at one way of doing just that.
Multiple levels of factories
The factory pattern can be a great way to isolate the creation of larger objects - such as view controllers and some of the core objects that we use throughout our app. But even better, is if we'd create multiple factories that each deal with a certain level - or scope - of our app.
For example, we might start by creating a RootFactory
that's able to create all objects that don't require any specific models or state in order to work. Into that factory, we can inject all sorts of singletons that we rely on - such as our own UserManager
, as well as system-provided ones like URLSession.shared
:
class RootFactory {
private let urlSession: URLSession
private let userManager: UserManager
init(urlSession: URLSession = .shared,
userManager: UserManager = .shared) {
self.urlSession = urlSession
self.userManager = userManager
}
}
With the above in place, we can now start defining methods on RootFactory
, that each gives us an easy way to create some of the objects that we'll need throughout our app, such as an ImageLoader
and the view controller used to log into our app.
Since our factory already holds all required system dependencies, the caller will only need to call a method without any arguments, and our factory can make sure to inject all the dependencies that our objects need - which might include the factory itself:
extension RootFactory {
func makeImageLoader() -> ImageLoader {
return ImageLoader(urlSession: urlSession)
}
func makeLoginViewController() -> UIViewController {
return LoginViewController(
userManager: userManager,
factory: self
)
}
}
Here comes the trick - instead of having to rely on non-optional optionals and global state - we'll create additional factories that each are bound to a specific model. For example, in an email app we might have a MessageBoundFactory
that can create all objects that rely on an instance of an email message - or in our case, we create a UserBoundFactory
that's bound to the currently logged in user - like this:
class UserBoundFactory {
private let user: User
private let rootFactory: RootFactory
init(user: User, rootFactory: RootFactory) {
self.user = user
self.rootFactory = rootFactory
}
func makeProfileViewController() -> UIViewController {
let imageLoader = rootFactory.makeImageLoader()
return ProfileViewController(
user: user,
imageLoader: imageLoader
)
}
}
As you can see above, our UserBoundFactory
also uses its parent RootFactory
in order to create the objects that don't require the current user - which works since factories never retain the objects that they create - so all "child factories" are free to retain their parent without having to worry about causing any retain cycles.
The beauty of the above approach is that once we get access to a UserBoundFactory
, we can simply create any user-bound object that we need without having to constantly pass the user around, all without requiring any assertion failures or risking undefined states. In the case of ProfileViewController
, we simply call makeProfileViewController
just as before, but we now have a compile-time guarantee that the required data is actually available.
Finally, let's create our lock, which takes the shape of a method on RootFactory
that makes it possible to retrieve a user-bound factory given that the required key - a user model - is available to the caller:
extension RootFactory {
func makeUserBoundFactory(for user: User) -> UserBoundFactory {
return UserBoundFactory(user: user, rootFactory: self)
}
}
Not only have we made our code much more predictable by removing all non-optional optionals and assertion failures, we've also improved the separation of concerns in our code base - since each factory is only responsible for creating objects within its own scope 👍.
Putting things into practice
Many architectural constructs look good "on paper", but the question is always how well they work in practice - so let's take our new locks and keys implementation for a spin!
As we saw earlier, our LoginViewController
is now created by our RootFactory
, which also passes itself into the view controller as part of its initializer. The benefit of that is that as soon as a user has successfully logged in, we can use the injected RootFactory
to open our lock and get access to a UserBoundFactory
, which we can then use to create a ProfileViewController
and push that onto the navigation stack - like this:
private extension LoginViewController {
func handleLoginResult(_ result: Result<User>) {
switch result {
case .success(let user):
let userBoundFactory = factory.makeUserBoundFactory(for: user)
let profileVC = userBoundFactory.makeProfileViewController()
navigationController?.pushViewController(profileVC, animated: true)
case .failure(let error):
show(error)
}
}
}
Since each factory is able to inject itself into the objects it creates, we don't need to retain any factory in any specific place - the ownership of each factory is simply shared among all objects that are currently using it, and as soon as it's no longer needed (in the case of UserBoundFactory
- whenever the user logs out), it will automatically be released.
One of the major benefits of factories, especially when used together with locks and keys, is that we can easily hide implementation details related to how and why a given object is created. We could even go so far as to let our RootFactory
decide what the root view controller of our app should be - by either using user information stored in the keychain to unlock its own lock, or otherwise returning a LoginViewController
:
extension RootFactory {
func makeRootViewController() -> UIViewController {
if let user = userManager.restoreFromKeychain() {
let factory = makeUserBoundFactory(for: user)
return factory.makeProfileViewController()
}
return makeLoginViewController()
}
}
That way, when we're setting up our app in its AppDelegate
, we can simply create our RootFactory
and have it give us the right root view controller to use, like this:
let factory = RootFactory()
let rootVC = factory.makeRootViewController()
window.rootViewController = UINavigationController(
rootViewController: rootVC
)
With the above in place, our app's state is hidden even from our app delegate, which otherwise tends to become an object that needs to hold a lot of global state - most likely in the form of (occasionally non-optional) optionals.
Conclusion
Using the locks and keys principle, to only enable access to certain objects once their required dependencies are available, can be a great way to make our code base more predictable - and reduce the need for non-optional optionals and awkward guard
statements. By using multiple levels of factories, each hidden behind a clearly defined lock, we can essentially create multiple scopes within our app - that each becomes increasingly specialized for a given task or piece of data.
However, using factories is not the only way to implement locks and keys. Much of the same principle can be achieved using other techniques as well - such as using a functional approach where each function unlocks a new set of APIs, or setting up multiple levels of dependency containers. Like with most things, there are multiple paths to take, that each lets us fulfill the same goal.
What do you think? Do you currently use locks and keys to manage the objects in your app, or is it something you'll try out? Let me know - along with your questions, comments or feedback - on Twitter @johnsundell.
Thanks for reading! 🚀