Skip to content

Best SwiftUI Enterprise Design Patterns

, | November 13, 2023 | By

In early 2022, a client approached RCG with a request to develop a large-scale enterprise app built to last for years. Given the scope of the project, we decided to take on the challenge of building the app using SwiftUI.

As the architect of our iOS team at RCG, I was tasked with planning the app’s architecture. However, adopting SwiftUI for such an extensive project presented a unique challenge due to the lack of well-established architectural practices. My journey to choosing (and eventually inventing) a design pattern occurred in three phases.

Phase One: Choosing a UIKit Friendly Architecture

As an experienced UIKit developer, my first inclination was to choose an architectural pattern that worked well in the UIKit world. This proved to be problematic, the reasoning for which is best expressed after a brief history lesson.

A Brief History of iOS Architecture

When Apple released the iPhone SDK in 2008, the recommended design pattern to build around your UIKit application was MVC (Model-View-Controller). From a high level, this pattern involves your data model, your view, and a controller that ties them together (although in UIKit your view and controller are combined into a single “view controller”). Once the complexity of applications grew beyond the basics, this pattern often resulted in a familiar problem: massive view controllers.

Massive view controllers came about for a myriad of reasons. For instance, it’s incredibly hard to use view composition (breaking down a larger view into smaller subviews) within UIKit, so most developers give up and just create a single view controller per entire screen of an app, bloating each view controller. In addition, most view controllers end up containing common functionality like networking, further adding to the bloat. State management (one of the most important aspects of a mobile application) was also tricky – storage and management of application state was largely left up to the developer, so all of it ended up in the view controller by default.

All in all, the problem came down to view controllers having too much responsibility. In order to mitigate this issue, developers used a myriad of additional design patterns that were meant to solve the problem of massive view controllers by separating the concerns normally held by view controllers into separate components:

  • MVP (Model-View-Presenter): Introduces “presenter” components that handle communication between each view controller and the data model. 
  • MVVM (Model-View-ViewModel): Similar to MVP, each view controller gets a component that facilitates communication with the data model, this time called a “View model”. View models are distinct from presenters in that they are responsible for storing view state and they bind that state directly to their associated views.
  • Coordinator: Coordinator components separate the concern of navigation from view controllers.
  • VIPER (View-Interactor-Presenter-Entity-Router): Arguably the most complex of the bunch, VIPER seeks to separate each concern normally held by a view controller into its own individual component. The View (view controller) only handles displaying UI, the Presenter manipulates data for the View, the Interactor handles business logic, the Entity represents basic data models, and the Router takes care of navigation.

Enter SwiftUI

In 2019, Apple introduced a new UI framework, SwiftUI. SwiftUI is very different from UIKit. Where UIKit is imperative, SwiftUI is declarative; rather than the developer dealing with views directly, they specify how the UI should look and the framework handles the rest. Rather than developers handling state management themselves, SwiftUI has built in state management and handles view updates upon state changes automatically. Where view composition is difficult in UIKit, it’s the default in SwiftUI.

As you can see, SwiftUI is not just a new UI framework; it’s so different from UIKit that it completely changes the game we’re playing. Most of the circumstances that created the massive view controller problem in UIKit don’t exist in the world of SwiftUI, which means that we don’t need to use design patterns that were specifically designed to solve this problem.

So even though choosing a UIKit-friendly design pattern seems to make sense at first glance, one quickly realizes that these patterns would be solving nonexistent problems in the world of SwiftUI. Not only that, but some of these patterns (like VIPER), might actively discourage developers from writing good SwiftUI code. Patterns like VIPER create significant boilerplate on a per-view basis, which discourages developers from splitting up views into smaller chunks since each view adds so much code and complexity. Michael Long has written about this extensively.

All in all, design patterns should be solving problems rather than potentially creating them. UIKit friendly architectures were conceived of to solve the massive view controller problem, which is no longer present in SwiftUI.

Phase Two: Choose the Most Recommended SwiftUI Architecture: MVVM

Okay, so applying my UIKit knowledge to this problem isn’t going to solve it. I then scoured the web and found that most people recommend MVVM as the standard SwiftUI design pattern.

As discussed above, MVVM dictates that each view gets its own ViewModel component that will contain its state and business logic, and the ViewModel will bind its state directly to its view. This sounds like a great candidate for the state-driven, binding-based structure of SwiftUI, and we can achieve it easily using ObservableObject (as well as the new @Observed macro).

There are pros and cons to this approach, the pros being that MVVM separates our business logic from the view, making it portable and easy to test. However, the cons of using MVVM with SwiftUI are vast and in my opinion, outweigh the pros:

  • Developers are unable to use SwiftUI features like withAnimation, @FocusState, @FetchRequest, etc within ViewModels. This is because view models are just normal classes – they’re not views and therefore cannot utilize the feature set of views. Getting around this with workarounds can be cumbersome.
  • Passing data between SwiftUI views becomes a challenge because we can no longer easily use bindings or access information from the environment within view models (through @Environment or @EnvironmentObject)
  • Changing a single property in your ViewModel invalidates the entire view it’s attached to, rather than just the views that this specific piece of state touches (this is fixed with the new observation system in iOS 17, but still worth mentioning for versions of SwiftUI prior to iOS 17)
  • MVVM’s tight coupling of business logic with views might discourage view composition. Breaking up a single view into several views would mean creating several new view models and orchestrating potentially complex data flow between them.

Given the drawbacks when applied to SwiftUI, I feel that MVVM is not the right tool for the job. The pattern effectively separates view and business logic but puts up a plethora of roadblocks between developers and core SwiftUI features in the process. I think we can come up with something better suited for the world of SwiftUI. It also looks like I’m not alone in this opinion.

Phase Three: Try Something New

So now what? The wonderful (and terrifying) thing about using a fairly new framework like SwiftUI is that best practices haven’t yet been established. The more you look for others who have plowed the path for you, the more you realize that you’re one of the plows. With this in mind, I realized that we’ll need to chart our own course, that we’ll need to come up with our own architectural pattern from scratch.

Taking a step back: what is the point of software architecture, anyway? I guess we’re just supposed to do it, right? To please the architectural gods? 

I beg to differ. My philosophy on architecture is that it should have purpose: it should solve problems. By its nature, SwiftUI has solved the problems that created massive view controllers; this is exciting because it means that we can focus our efforts on solving other, more general architectural problems, and the resulting architecture could be more simple and elegant than UIKit focused architectures of the past.

So then, what problems are we trying to solve with more general architecture? Typically we’re trying to prevent spaghetti code; we want to end up with a structure to our code that is easy to understand and is easy to scale. Generally, we are shooting for maintainable code. Here are the general principles I chose for this new architectural pattern:

  1. Separate concerns (e.g. separate views from business logic)
  2. Keep things DRY – common tasks should be relegated to their own utilities
  3. The pattern should complement SwiftUI and not fight the framework

MVSU (Model-View-Service-Utility)

With these requirements in mind, I conceived a design pattern that I call MVSU. The pattern has strong roots in MVC, domain-driven design, and Clean architecture, but it has unique properties that I think make it an especially good fit for SwiftUI. The pattern is made up of 4 groups of components:

  • Utilities are narrowly focused, reusable tools that perform a single specific task or a few highly related tasks. They allow services to perform their functions without worrying about lower-level details.
  • Services are the business logic layer. A service is a stateless group of functionality that pertains to a specific domain of your application. Think of this as the API that your view uses.
  • The Model is the least unique piece, it’s just the encapsulation of your app’s data. Services communicate with the model to perform their functions.
  • Views are also pretty self-explanatory, but there are a few rules to be aware of. Views are largely responsible for the handling of their own state, and they primarily use services (as opposed to utilities) to perform their functions. We use view composition heavily to break down our views into easily digestible components, which keeps each view small and narrowly focused.

I’ll go into more detail on each type of component:

Utilities

In some apps, common tasks like networking, displaying alerts, handling errors, etc can end up being strewn and repeated throughout the codebase (or it ends up in your mediation layers like viewmodels). MVSU specifically dictates that we move these types of repeated tasks into classes with the suffix “utility” (e.g. NetworkUtility, SessionUtility, LoggingUtility, etc). 

In adhering to the single responsibility principle, these utilities should be highly focused – they should only perform one type of task or a few highly related tasks. Services then utilize utilities to perform their higher-level functions. By moving tasks like this into utilities, we keep the codebase DRY (don’t repeat yourself) and we allow services to focus only on what is unique to their domain. The rule of thumb here is that if multiple services are performing the same logic, it probably needs to be moved to a utility.

class NetworkUtility {
   func perform<ResultType: Decodable>(request: Request<ResultType>) async throws -> ResultType {
      // ...
   }
}

class LogUtility {
   func log(type: LogType, description: String) {
      // ...
   }
}

class FileUtility {
   func save(data: Data, toFileUrl fileUrl: URL) throws {
      // ...
   }

   func retrieveData(fromFileUrl fileUrl: URL) throws -> Data {
      // ...
   }
}

Services

Many patterns like MVC, MVP, MVVM, and VIPER separate business logic by view (i.e. each view has its own separate controller, viewmodel, etc). In MVSU, we separate our business logic by domain – this means that each type of data that your app consumes gets its own service (e.g. for a donut shop, this might look like DonutService, OrderService, UserService, etc).

Separating business logic by domain has a few advantages:

  • Better separation of concerns: since mediators like view models are designed to be attached to a particular view, they end up knowing a fair amount about that associated view. Separating by domain means that services know a lot about their domain, but very little about views (if anything at all). This makes them more portable than view models since they aren’t designed with any particular views in mind.
  • Logically related code ends up logically grouped. In an MVVM app with separate views for creating and deleting a donut, your donut creation and deletion code would end up in 2 different viewmodels even though this code is highly related. In MVSU, this code would all end up in a single DonutService, making related code easier to maintain.
  • Functionality can be easily shared between views since a service function could be used by more than one view. This is in opposition to MVVM where we have a single viewmodel per view.
  • Separating by domain leads to less boilerplate in the view-composition world of SwiftUI. In patterns like MVVM and VIPER, developers disincentivized from creating subviews because of the boilerplate they’d be forced to write (e.g. viewmodel, router, presenter), in MVSU there is no such boilerplate per-view, the boilerplate is per-domain instead. This removes any downside to breaking down views into subviews. This is hugely important as view composition is the primary reason that SwiftUI has solved the massive view controller problem, so we must encourage it in our architecture.

Another thing to note about services is that they’re stateless – state should be stored in views, not services. More details about this in the Views section.

// Note that dependency injection strategy is up to you, but I recommend using a library like Factory (https://github.com/hmlongco/Factory), which is the strategy employed below 

import Factory

class OrderService {
   @Injected(\.networkUtility) private var networkUtility
   @Injected(\.logUtility) private var logUtility

    func createOrder(items: [ItemModel]) async throws -> OrderModel {
        do {
            let response = try networkUtility.perform(request: .createOrder(items: items))

            return OrderModel(record: response.order)
        } catch {
            logUtility.log(type: .error, description: "Order error occurred: \(error.localizedDescription)")

            throw RuntimeError("Unable to create order")
        }
    }
}

Views

As mentioned, MVSU encourages developers to use view composition and split up large views into smaller views, keeping each view lean and narrowly focused and avoiding the massive view controller problem of the past.

In MVSU, services are stateless – instead, views maintain all of their necessary state. This is for a few reasons:

  • Separation of concerns: arguably, the application state is a view concern, not a domain concern. By keeping the concerns of views within views, we end up with more maintainable code
  • Enhanced composability and simplicity: Passing data up and down the view hierarchy can be a confusing challenge when dealing with intermediate layers like view models. Using @Binding and @Environment by comparison is very simple and therefore incentivizes composability.
  • Easier utilization of SwiftUI features: keeping state within views helps us to easily utilize features like withAnimation.
struct OrderView: View {

    @Injected(\.orderService) private var orderService
    @Injected(\.paymentService) private var paymentService


    let availableItems: [ItemModel]
    @State private var itemsInCart: [ItemModel]
    @State private var orderAwaitingPayment: OrderModel?
    @State private var error: Error?

    var body: some View {
        VStack {
            ForEach(availableItems) { availableItem in
                AddItemToCartButton(availableItem) { item in
                    itemsInCart.append(item)
                }
            }

            Divider()

            // I Highly recommend creating an AsyncButton view: https://www.swiftbysundell.com/articles/building-an-async-swiftui-button/
            AsyncButton("Create Order") {
                do {
                   orderAwaitingPayment = await orderService.createOrder(items: itemsInCart)
                } catch let orderError {
                   error = orderError
                }
            }
        }
        .sheet(item: $orderAwaitingPayment) { order in
            PaymentInfoView(order: order)
            PaymentButton() {
                await paymentService.createPayment(forOrder: order)
                orderAwaitingPayment = nil
            }
        }
        .errorAlert(error)
    }
}

Model

This is the least unique part of the pattern. Your model involves your data types and any persistence layer needed (e.g. Core Data, SwiftData, Realm, etc). Note that maintaining SwiftUI’s feature set is paramount in MVSU, so views should be allowed to communicate with the model directly through SwiftUI’s property wrappers like @FetchRequest or @Query.

Conclusions

In my opinion, the introduction of SwiftUI has been the most significant transformation ever in the realm of iOS development. SwiftUI is not just a new UI framework – it’s so different from UIKit that I think it begs us to rethink the entire way we build apps. It might be time for all of the iOS community to take a step back and think about why we make the architectural decisions we do in the first place.

My journey to choosing, and eventually creating, a design pattern made me realize why we were using common UIKit design patterns in the first place. In my opinion, UIKit isn’t well suited for the level of complexity reached by most apps nowadays, which created problems that we needed to solve with specifically designed architectural patterns. SwiftUI doesn’t have those same problems, so there’s no need for their solutions.

This new landscape is both terrifying and exciting. This inevitably means that some of the knowledge and skills that we’ve been honing in the UIKit world need not apply. It also means that we get to chart our own course, and maybe we’ll never again have to deal with some of the worst parts of UIKit development. I for one am excited to continue this journey through uncharted waters.