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.
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.
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:
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.
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:
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.
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:
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:
I’ll go into more detail on each type of component:
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 {
// ...
}
}
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:
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")
}
}
}
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:
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)
}
}
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.
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.