Clean Xcode project architecture is an onion that can be peeled a million different ways. And one thing most iOS engineers agree is that Model-View-Controller (MVC) is just a small slice of the architectural story.

A problem that comes up for most projects is the challenge of integrating dependencies, whether they be Apple frameworks, third-party SDKs, or simple HTTP APIs.

I'm not talking about dependency management in the vein of Swift Package Manager or CocoaPods. I'm talking about how to organize and integrate the code you write which makes use of those dependencies.

I use what I call an "integrations" pattern to plug in and unplug dependencies as neatly as possible. Here's how it works.

Integrations vs alternatives

This "integrations" pattern is not a complete architectural system. Rather, it’s simply a way to take a hard stance on separating third-party code out from the guts of your app. All third-party code, along with the code you write that uses such code, exists in its own silo. So when you inevitably find yourself swapping one dependency for another, your models and views don't need to be touched.

You can make this pattern work in a variety of architectural systems. It can be used with view models or not, and it's especially useful for patterns that don't already account for third-party dependencies like the umpteenth flavors of MVC you see in existing codebases.

Sure, I'm going to reference architectural concepts that I use to make this work (e.g. how I set up models and link them to my integrations). But I encourage you to take these as guideposts and allow yourself to internalize the high-level concepts which can then be adapted in other ways that suit your needs.

After all, I didn't invent the idea of modularizing dependencies. What I'm proposing here is a flavor I landed on after learning a lot from others in the iOS community. And I like it because of its flexibility and the architectural aesthetics it makes possible.

Models of your hearts desire

The best part of using integrations is getting to do whatever you want with your models. Swift is one of the most readable programming languages ever created. It has all manner of sexy considerations that serve its first and foremost API guideline: clarity at the point of use.

So I damn-well want to use these features. I want to use structs when they make sense. I want to employ generics and extensions to reduce code reuse. I want to use all the nifty features that come with enums. And I want to do this with full freedom, unapologetically.

What I don't want is to be limited by the specific nuances of whatever persistence framework I happen to be employing. I don't want to see @objc littering my model code, along with awkward names of collections that can sometimes come with frameworks like CoreData. I'm not saying it never makes sense to lean on something like CoreData. There's an awful lot of advanced features and benefits that can warrant allowing its tentacles to reach into your code. I'm just saying I like to resist doing so unless truly necessary.

I want my models to be sexy, to-the-point, simply-named, crystal-clear, fully Swift native representations of my data that get to use the latest and greatest features my deployment target permits.

So that's what I do, as best as I can. I design my models in an app-specific way to my heart's content. And then I defer the responsibility of talking to third party code to the integration implementation.

Services as a bridge

Your models are of course purely a representation of app state. So we need something on top of this layer to fetch and mutate this state. And we want this defined in terms of our app models, and completely (or at least as much as possible) without reference to how it's actually implemented.

A "service layer" is a common pattern that can take a few different forms. I like to define my services as protocols that can be implemented concretely within your integrations. For example, let's say our app needs to fetch Post objects as part of a social media feed. You might have a PostService that handles the fetching:

/// A service that fetches posts
protocol PostService {
    
    /// The result of fetching posts
    typealias FetchPostsResult = Result<[Post], Error>
    
    /// The completion handler called after an attempt to fetch posts
    typealias FetchPostsCompletion = (FetchPostsResult) -> Void
    
    /// Fetch posts for the user
    func fetchPosts(for user: User, _ completion: FetchPostsCompletion)
}

And you could implement this service however you like. If you are using a simple HTTP API, it might look like this:

/// An HTTP API implementation of a post service
struct MyBackendPostService: PostService {
    
    func fetchPosts(for user: User, _ completion: (FetchPostsResult) -> Void) {
        // TODO: Implement URL request for fetching posts
    }
}

Or you could use something like Firebase:

/// A firebase implementation of a post service
struct FirebasePostService: PostService {
    
    func fetchPosts(for user: User, _ completion: (FetchPostsResult) -> Void) {
        // TODO: Implement Firebase query for fetching posts
    }
}

It doesn't matter, because all your view code needs is some instance of PostService.

And in order to pass it into your view code, you create a ServiceProvider object as part of your Services layer, which is a bridge from your Integrations layer to your Services layer:

/// The object that provides concrete implementation of al services
struct ServiceProvider {
    
    static let postService: PostService = FirebasePostService()
}

This way, if you decide to switch up the implementation, you only need to edit the ServiceProvider struct which is in turn used by your views or store objects. Your views remain entirely unchanged!

Integrations are comprehensive

Integrations are intended to be comprehensive. That is, all code you write, of any kind, that directly interacts with a given third-party dependency lives inside the integration.

As such, I like to add an “Integrations” group (folder) along with all my other layers like so:

Then I add a group for each integration, each of which is a little universe unto itself with everything needed for the integration to work. In this case, I integrate the Facebook SDK, the Firebase SDK, the Airtable API for one of my Airtable "bases," and an HTTP API specific to my app:

Notice the Firebase integration. There’s a manager class that handles all initialization and high-level code needed for the Firebase SDK to run (e.g. key setup, calls in response to app lifecycle events, etc). There’s a group for all the app-specific services the integration implements (see above), and there’s also a group for Firebase integration-specific models and view controllers. We’ll get to why you’d ever need these later, but the point is that each integration has a group for each of the types of items needed for that integration to function.

Finally, there’s a high-level IntegrationsManager class that handles all setup and lifecycle events for all integrations. There’s nothing I hate more than littering one’s App Delegate with dependency setup, so all of that is delegated to this class.

Integrations are for views too

Integrations aren't purely about abstracting away things like persistence frameworks. They are about abstracting any kind of third-party code you might imagine, even views.

For example, Google's Firebase provides a fully fleshed-out authentication flow that you can drop right into your app, which can be a massive time saver for early-stage startups with small teams and limited budgets. It's not gorgeous, but it's fine for an MVP.

Thing is, I'm not interested in seeing Firebase view and view controller classes mixed in with my app specific view code. So integrations come to the rescue again!

As part of the integration, I create a FirebaseAuthenticationViewController which is a UIKit subclass of Firebase's FUIAuthPickerViewController which handles all my app-specific customization of the Firebase component. And since I'm using SwiftUI, I create a UIViewControllerRepresentable instance called FirebaseAuthenticationView that handles the translation from UIKit.

This is great because it keeps all my view code that uses the Firebase SDK with all the other integration code, separate from the guts of my app. But I also don't want to just drop in my FirebaseAuthenticationView into my view code, because that's just a more indirect way of littering my app-specific code with third-party dependencies.

So how do I get around this?

Ideally, we want our own SwiftUI view called AuthenticationView that we can add to our view hierarchy as desired. Our view accepts all dependencies in it's initializer as we see fit, and is the struct we present whenever we want to enter our auth flow.

But since we are deciding that its implementation will be deferred to a third-party framework, it's perfectly reasonable to require the implementation as an initializer parameter to our native view. And this is again where services come in.

If we were not using custom UI, we'd almost certainly have something like an AuthenticationService that has login and signup functions as so:

/// A service that managers user authentication
protocol AuthenticationService {
    
    /// The result of a login attempt
    typealias LoginResult = Result<User, Error>

    /// Login with the provided email and password
    func login(email: String, password: String, _ completion: LoginResult)
}

But since we're delegating our entire authentication flow to Firebase, it's not unreasonable to ask the service to provide that dependency like so:

/// A service that managers user authentication
protocol AuthenticationService {
    
        /// The view that manages authentication
    var authenticationView: AnyView { get }
}

Then we implement this service back in our integration, and return an instance of our FirebaseAuthenticationView like so:

/// A Firebase implementation of the authentication service
struct FirebaseAuthenticationService: AuthenticationService {
    
    var authenticationView: AnyView {
        return FirebaseAuthenticationView()
    }
}

This way, if we decided to use the next latest and greatest BAAS that provides a complete login flow, we'd just need to provide an alternative implementation of AuthenticationView and nothing more.

Now, I realize some of us may feel this isn't really an appropriate use of a "service." I personally have no problem with it if we just think of a service as a dependency provider. But I'm also open to using something like an AuthenticationViewProvider object and keeping services data-oriented. How you do this is up to you. The point though is that it's not so hard to abstract even view details away and designate them as dependencies to be injected via some passed in object whose API is app-specific. And if at some point we decide we want to roll our own solution, we just expand the service to include the functionality we want and remove the view.

Integration-specific models anyone?

Another group/folder I frequently have associated with an integration is a “models” folder. That’s right, I will occasionally decide to define model structs for use solely within an integration — even if a given model is already represented by a model I’ve already defined for the app.

Now that might have you wondering whether I’m actually a sane person. Models are tedious enough to setup as it is. Why on earth would someone ever define a model more than once?

There’s reasons, so hear me out.

Remember, my deepest desire for the models used throughout the app are to be designed in such a way that they have the following characteristics:

  1. They take full advantage of the latest and greatest Swift language features
  2. They are designed around “clarity at the point of use” within the app
  3. They are unaware of the implementation details of the services on which they depend

The problem is that using app-level models in an integration can sometimes violate one or more of these principles, allowing the tentacles of dependencies to reach places they shouldn’t.

For example, your backend JSON API will likely need Codable to serialize and deserialize models fetched and sent over HTTP. The problem is that the way your models are stored and architected on that API are not necessarily a one-to-one match with how you might optimize your models in the context of your app with the latest Swift features. Your implementations of Codable might be straightforward as adding the declaration and auto-synthesizing the implementation — or they might require logic specific to the API to translate the fetched JSON into your local model structs.

This may or may not be a problem. If your models are only ever interacting with one API, then it might be fine to implement Encodable and Decodable as extensions of the model struct but within the integration group. This way, if you swap out APIs, you can replace the implementations as needed without requiring changes to scope that effects the whole app.

But what if your app interfaces with multiple APIs and SDKs that need to know about your models? And what if each of these represents the data a little differently or only interfaces with some slice of each model?

For example, let’s say you have a Video model that’s used in your app to play streaming videos. Users can upload videos so they’re stored in Firebase as part of the user’s data, but your app also uses a third-party service for high-performance video hosting and streaming like JWPlayer. Thing is, the way each video is modeled in this third-party service is different from Firebase. Not only does it include less data about each video, but the data is modeled differently on JWPlayer due to API limitations.

In this case, where would you implement the Encodable and Decodable extensions? In the app-level models layer? At the top level of the integrations layer? Neither feels right. And either way, you’d have to have all this logic within that implementation to accommodate each specific case which can lead to unreadable chaos that’s impossible to maintain.

In cases like this, I find it valuable to separate the concerns completely. Firebase gets it’s own struct called FirebaseVideo that is designed around how a video is represented on Firebase. Likewise, the JWPlayer get’s its own struct called JWPlayerVideo. Each is modeled such a way that custom Encodable and Decodable implementations are straight-forward for the API. Then, any logic needed to translate back to the app specific model Video is done as an extension on the Video struct:

extension Video {
    
    init(_ video: FirebaseVideo) {
        self.id = video.id
        ...
    }
}

extension Video {
    
    init(_ video: JWPlayerVideo) {
        self.id = video.id
        ...
    }
}

It’s a lot easier, and less error-prone, to reason through the details of mapping the same model data represented in different ways at the level of Swift structs and initializers than it is to do so in the context of the serialization protocols.

Sure, it’s a little more tedious to have multiple model definitions, Codable implementations, and initializers — and I’m not saying having them is always warranted. But in a situation like this, the tedium is worth the vastly worse headache of reasoning through edge cases, not to mention that all this code gets to live exclusively within the integration for which it’s relevant.

Strong modularity ✅
Clarity at the point of use ✅
Fewer errors during serialization and deserialization ✅
No limits on your app-specific models ✅
Happy feelings ✅

Discussion

Categories
Share Article

Continue Reading