Splitting up Swift types
The idea that an app’s various features and systems should ideally be kept clearly separated in terms of their responsibilities and concerns is something that’s quite widely accepted across the entire software industry. So many architectural patterns, techniques and principles have been invented over the years in attempts to guide us into writing more clearly decoupled code — both in Swift and in many other languages as well.
However, regardless of which kind of architecture that we’ve chosen to adopt within any given project, making sure that each of our types has a narrow and clearly defined set of responsibilities can at times be quite challenging — especially as a code base keeps evolving with new features, and in response to platform changes.
This week, let’s take a look at a few tips and techniques that can help us do just that, by splitting our types up once their responsibilities have started to grow beyond the ideal scope of a single type.
States and scopes
One really common source of code complexity is when a single type needs to handle multiple scopes and separate states. For example, let’s say that we’re working on the networking layer of an app, and that we’ve currently implemented that entire layer within a single class called NetworkController
:
class NetworkController {
typealias Handler = (Result<Data, NetworkError>) -> Void
var accessToken: AccessToken?
...
func request(_ endpoint: Endpoint,
then handler: @escaping Handler) {
var request = URLRequest(url: endpoint.url)
if let token = accessToken {
request.addValue("Bearer \(token)",
forHTTPHeaderField: "Authorization"
)
}
// Perform the request
...
}
}
Above we’re using an Endpoint
type to define the various server endpoints that our app is communicating with. Check out “Constructing URLs in Swift” for more information on that approach.
While implementing an entire feature or system within a single class is not necessarily a bad thing, in this case, doing so has left us with a quite major source of ambiguity. Since we’re using the same API to request both public endpoints, as well as those that require authentication, each of the developers on our team needs to always remember which endpoint that belongs to which group — or else we’ll end up with runtime errors when a protected endpoint was accidentally requested without a logged in user.
It would arguably be a lot better if we could utilize Swift’s type system to prevent any endpoint that requires authentication from being called without a valid access token. That way we would both be able to validate our networking code much more thoroughly at compile time, and also make that system easier to use — as it’d be crystal clear which endpoint that may be requested within any given scope.
To make that happen, let’s start by moving all of the code that deals with authentication and access tokens out from NetworkController
and into a new variant of that class, which we’ll name AuthenticatedNetworkController
. Just like its predecessor, our new controller will enable us to perform endpoint-based network calls — only this time we’ll both initialize it with its required tokens, and we’ll also ensure that those tokens are kept up to date before we perform each request, like this:
class AuthenticatedNetworkController {
typealias Handler = (Result<Data, NetworkError>) -> Void
private var tokens: NetworkTokens
...
init(tokens: NetworkTokens, ...) {
self.tokens = tokens
...
}
func request(_ endpoint: AuthenticatedEndpoint,
then handler: @escaping Handler) {
refreshTokensIfNeeded { tokens in
var request = URLRequest(url: endpoint.url)
request.addValue("Bearer \(tokens.access)",
forHTTPHeaderField: "Authorization"
)
// Perform the request
...
}
}
}
Worth noting is that we’ve also given our new network controller its own, dedicated endpoint type — AuthenticatedEndpoint
. That’s to also clearly separate our endpoint definitions, so that an endpoint which requires authentication won’t accidentally be passed to our previous NetworkController
.
Since that type no longer has to deal with any authenticated requests, we can heavily simplify it, and rename it (and its endpoint type) to something that better describes its new role within our networking layer:
class NonAuthenticatedNetworkController {
typealias Handler = (Result<Data, NetworkError>) -> Void
...
func request(_ endpoint: NonAuthenticatedEndpoint,
then handler: @escaping Handler) {
var request = URLRequest(url: endpoint.url)
...
}
}
However, while the above kind of separation of concerns can give us a huge boost in terms of architecture and API clarity, it can also require a fair amount of code duplication. In this case, both of our network controllers need to create URLRequest
instances and perform them, as well as handling tasks like caching and other networking-related operations — so they could still share most of their underlying implementations, even though we wish to keep their APIs separate.
For starters, rather than having each of our networking-related types declare their own completion handler closure types, let’s define a generic one that they can easily share:
typealias NetworkResultHandler<T> = (Result<T, NetworkError>) -> Void
We can then start moving parts of our underlying networking implementations out from the controllers themselves, and into smaller, dedicated types — which should be able to remain fairly stateless. For example, here’s how we could create a private NetworkRequestPerformer
type that both of our two controllers can use to actually perform their requests — while still keeping our top-level APIs completely separate and type-safe:
private struct NetworkRequestPerformer {
var url: URL
var accessToken: AccessToken?
var cache: Cache<URL, Data>
func perform(then handler: @escaping NetworkResultHandler<Data>) {
if let data = cache.data(forKey: url) {
return handler(.success(data))
}
var request = URLRequest(url: url)
// This if-statement is no longer a problem, since it's now
// hidden behind a type-safe abstraction that prevents
// accidential misuse.
if let token = accessToken {
request.addValue("Bearer \(token)",
forHTTPHeaderField: "Authorization"
)
}
...
}
}
With the above in place, we can now let both of our network controllers focus solely on providing a type-safe API for performing requests — while their underlying implementations are being kept in sync through privately shared utility types:
class AuthenticatedNetworkController {
...
func request(
_ endpoint: AuthenticatedEndpoint,
then handler: @escaping NetworkResultHandler<Data>
) {
refreshTokensIfNeeded { [cache] tokens in
let performer = NetworkRequestPerformer(
url: endpoint.url,
accessToken: tokens.access,
cache: cache
)
performer.perform(then: handler)
}
}
}
What we’ve essentially done above is to utilize the power of composition, in that we’ve shared various implementations by combining smaller types into the ones that define our public API. However, before we were able to compose our functionality, we first had to decompose the single type that we started out with. Doing that kind of decomposition on an ongoing basis is often key in order to keep a code base in tip-top shape, as our types tend to naturally grow as we add new features and functionality to our code base.
Loading versus managing objects
Next, let’s take a look at another type of situation which can make certain parts of a code base more complex than they need to be — when the same type is responsible for both loading and managing a given object. A very common example of that is when everything related to user sessions has been implemented within a single type — such as a UserManager
.
For example, here such a type is responsible for both logging users in and out of our app, as well as keeping the currently logged in User
instance in sync with our server:
class UserManager {
private(set) var user: User?
...
func logIn(
with credentials: LoginCredentials,
then handler: @escaping NetworkResultHandler<User>
) {
...
}
func sync(then handler: @escaping NetworkResultHandler<User>) {
...
}
func logOut(then handler: @escaping NetworkResultHandler<Void>) {
...
}
}
Like we took a look at in “Managing objects using Locks and Keys in Swift”, the main problem with the above sort of approach is that it forces us to implement our User
property as an optional — which in turn requires us to unwrap that optional within all features that somehow deals with the currently logged in user — most of which likely rely on that value in order to actually perform their work.
To quote the article about Locks and Keys that’s linked above:
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.
So, without having to fully adopt a Locks and Keys-based architecture within our app — how could we use some of the principles from that design pattern in order to split our UserManager
up into smaller, more focused types?
The first thing we’ll do is to extract all of the code related to loading User
instances out from UserManager
and into a new, separate type. We’ll call it UserLoader
, and it’ll use our AuthenticatedNetworkController
from before in order to request our server’s user
endpoint, which requires authentication:
struct UserLoader {
var networkController: AuthenticatedNetworkController
func loadUser(
withID id: User.ID,
then handler: @escaping NetworkResultHandler<User>
) {
networkController.request(.user(withID: id)) { result in
// Decode the network result into a User instance,
// then call the passed handler with the end result.
...
}
}
}
By decomposing our UserManager
into smaller building blocks, like we did above, we can enable much of our functionality to be implemented as stateless structs — since those types will simply perform tasks on behalf of other objects (just like our NetworkRequestPerformer
from before).
We can keep doing the same thing with our login and logout code as well, for example by creating a LoginPerformer
that uses our non-authenticated network controller to send a set of credentials to the server endpoint used to log a user into our app:
struct LoginPerformer {
var networking: NonAuthenticatedNetworkController
func login(
using credentials: LoginCredentials,
then handler: @escaping NetworkResultHandler<NetworkTokens>
) {
// Send the passed credentials to our server's login
// endpoint, and then call the passed completion handler
// with the tokens that were returned.
...
}
}
The beauty of the above approach is that we can now use either of our new types whenever we need to perform that type’s specific task, rather than always having to use the same UserManager
type regardless of whether we’re logging in, out, or simply updating the current user.
For example, within our login code, we can now use LoginPerformer
directly — and we can then use UserLoader
to load our newly logged in User
before injecting both of those instances into our UserManager
— which now only has a single responsibility, to manage our current User
instance:
class UserManager {
private(set) var user: User
private let loader: UserLoader
init(user: User, loader: UserLoader) {
self.user = user
self.loader = loader
}
func sync(then handler: @escaping NetworkResultHandler<User>) {
loader.loadUser(withID: user.id) { [weak self] result in
if let user = try? result.get() {
self?.user = user
}
handler(result)
}
}
}
We could even go ahead and rename the above type to UserModelController
, since it’s now essentially a controller for our user model.
Not only did the above refactor let us get rid of an unnecessary optional — which in turn should let us remove a whole lot of awkward if
and guard
statements from any code that relies on a user being logged in — it’ll also give us a greater degree of flexibility, since we’re now able to pick which level of User
-related abstraction that we’d like to work with within each new feature that we’ll build.
Conclusion
Composition is an incredibly powerful concept, but before we can utilize it within our apps, we first need to decompose some of our larger types into smaller building blocks — which can then be assembled into many different combinations and configurations.
Of course, we always have to try to strike a balance between splitting things up and still keeping our code base consistent and easy to navigate — so the goal is definitely not to split things up as much as possible, but rather to create types that have a narrow set of responsibilities, which can then be combined into higher-level abstractions.
What do you think? When do you typically decide to split a type up into multiple smaller ones? Let me know — along with your questions, comments and feedback — either via Twitter or email.
Thanks for reading! 🚀