Since the introduction of SwiftUI, the MVVM pattern has seen a new renaissance. Many developers believe that this particular pattern fits well with the SwiftUI data flow.
MVVM certainly has some good ideas, but it also brings along problems because of the various discording interpretations of the pattern and its rigidity.
In this article, we will see how MVVM fits in iOS apps written in SwiftUI, how to take advantage of its benefits, and how to avoid its problems.
Contents

Architecting SwiftUI apps with MVC and MVVM
GET THE FREE BOOK NOWChapter 1:
How MVVM improves the structure of iOS apps


MVVM is an architectural pattern that helps you structure your iOS apps. While it’s a derivation of MVC, it sports some unique ideas that fit well with how SwiftUI apps work.
The structure of the MVVM pattern and the roles of its layers
If I had to define MVVM quickly, I would say that it’s a variation of the MVC pattern.
But let’s proceed in order.
Like MVC, the Model-View-ViewModel pattern, or MVVM in short, is an architectural pattern that guides how you structure the code in your iOS apps. MVVM consists of three layers, from which it takes its name.
Each layer has a well-defined role in the app’s structure, helping you respect the separation of concerns design principle.
- In the model layer, we find the Swift types representing your app’s data and its domain business logic.
- The view layer is what the user sees on the screen. In iOS, it contains the SwiftUI views for your app, which display information and allow user interaction.
- Finally, a view model connects a view to the app’s model. A view model contains the current state of a view, links to other parts of an app’s architecture (e.g., data storage or the network), and handles the user’s interaction.
The MVVM pattern is not unique to iOS. In fact, it was invented by Microsoft architects (of all people). It found its way into iOS apps only years after the release of the first iPhone.
Traditionally, Apple has followed the MVC pattern for both macOS and iOS apps. That changed with the introduction of SwiftUI.
MVVM uses binders to connect views and view models
Looking at the MVC pattern diagram, it does not take a genius to see that MVVM is, practically speaking, the same pattern. For that reason, I usually talk about architecture in terms of MVC, although I use ideas from both patterns.
The most crucial difference with MVC is that MVVM connects the view and the view model layers using a binder. This synchronizes the data between the two layers, removing boilerplate code.
That’s a natural part of SwiftUI.
In UIKit, developers used an FRP framework like RxSwift. SwiftUI instead uses, behind the scenes, Combine, Apple’s native reactive framework. But you don’t need to learn Combine to make SwiftUI apps.
So, in SwiftUI, the most significant difference between MVVM and MVC has disappeared. While SwiftUI offer several data flow mechanisms, there is only “one” way to connect objects and views:
- An object must conform to the
ObservableObject
protocol. - Such an object must expose any property that affects the user interface using the
@Published
property wrapper. - Views connect to observed objects through the
@StateObject
,@ObservedObject
, and@EnvironmentObjects
property wrappers.
MVVM vs. MVC: local view models and of global controllers
There is another fundamental difference between MVC and MVVM.
In MVC, the emphasis has always been on making controllers globally shared objects. It was not uncommon to use singletons for that, although dependency injection became, with time, the preferred alternative.
In MVVM, instead, each view gets its separate view model. Objects, then, move from global to local.
In UIKit, both interpretations were possible because there was a fourth, not-so-hidden layer: view controllers. These sat squarely between views and controllers/view models. Since they were required by the framework, it was impossible to get rid of them.
That does not happen in SwiftUI. If you make an iOS app with more than one screen, you soon conclude that both local and global states are needed.
That’s why SwiftUI offers three property wrappers instead of just one. So, again the difference between MVC and MVVM disappears. We can have both controllers (global) and view models (local) in a full app.
Chapter 2:
The model layer is the foundation of an app’s architecture


Any iOS app needs solid foundations or the whole castle crumbles. These foundations are the model types, which represent an app’s data.
These are not inert values, though. They contain the domain business logic and the code for data transformation.
Creating model types to represent the data of an app
As an example, we will build a small app for Hacker News, a news website for developers like Reddit, known for its (debatable) quality.
We will use its simple web API to fetch the top 10 news stories from the best stories page.
You can find the complete Xcode project on GitHub.
As it’s often the case, we will start creating the model layer of our app. These are the easiest to implement and the foundation for the whole app’s architecture.
Model types only contain data and the code that manipulates it. They should not know anything about data storage, networking, or how data is presented to the user. In short, they should not know anything about the other layers of the MVVM pattern.
The Hacker News API uses a single item entity to represent all its data. Stories, comments, jobs, etc. are all items. So, creating a corresponding Swift type is straightforward:
1 2 3 4 5 6 7 8 9 |
struct Item: Identifiable { let id: Int let commentCount: Int let score: Int let author: String let title: String let date: Date let url: URL } |
JSON decoding goes inside the model layer
Since the Hacker News API returns data in JSON format, our Item
struct must conform to the Decodable
protocol and provide some coding keys to map the JSON fields to our properties. You can read more about JSON decoding in my Codable article.
1 2 3 4 5 6 7 8 |
extension Item: Decodable { enum CodingKeys: String, CodingKey { case id, score, title, url case commentCount = "descendants" case date = "time" case author = "by" } } |
This is an example of code that manipulates data. Model types are not just empty containers. Data transformation code goes into model types together with the domain business logic.
Granted, our Item
type has no business logic. That often happens for data we fetch from a web API. You can find an example in my free guide on MVC and MVVM in SwiftUI.
While we are at it, we can also create some test data we will use later for our SwiftUI previews. First, we need to grab the information for a story from the API, which we can save in a .json file in our Xcode project.
1 2 3 4 5 6 7 8 9 10 11 |
{ "by" : "theafh", "descendants" : 312, "id" : 24777268, "kids" : [ 24778001, 24788825, 24777957, 24778210, 24778026, 24779203, 24778283, 24780437, 24778385, 24779634, 24779048, 24777921, 24779934, 24787940, 24781384, 24779512, 24784656, 24782672, 24779975, 24779050, 24787912, 24785350, 24784794, 24785141, 24780574, 24778706, 24778457, 24780008, 24780571, 24779757, 24785643, 24778735, 24779575, 24778254, 24777996, 24778341, 24777945, 24777875, 24779393, 24780180, 24782796, 24779270, 24780626, 24779061, 24778700, 24781183, 24779653, 24779074, 24778439, 24777831, 24779329, 24778009 ], "score" : 1082, "time" : 1602687710, "title" : "Room-Temperature Superconductivity Achieved for the First Time", "type" : "story", "url" : "https://www.quantamagazine.org/physicists-discover-first-room-temperature-superconductor-20201014/" } |
Then, we decode it inside a TestData
structure to make it easy to use in any SwiftUI preview.
1 2 3 4 5 6 7 8 9 |
struct TestData { static let story: Item = { let url = Bundle.main.url(forResource: "Story", withExtension: "json")! let data = try! Data(contentsOf: url) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .secondsSince1970 return try! decoder.decode(Item.self, from: data) }() } |
Again, refer to my Codable
article linked above for more details about JSON and test data.
Chapter 3:
Organizing the view layer and simplifying view models


SwiftUI makes it simple to structure views, but it also makes it simple to couple their code to other types, making them less reusable. In this chapter, we will see how to keep view types decoupled from the rest of the app’s code.
Views should be independent of model types
We will now jump to the other edge of the MVVM pattern and create our views.
I am skipping the view model layer because it’s the most complicated. Moreover, it’s hard to write a view model’s code when we don’t have its related view.
We can start with some simple views to represent the position, upvotes, and comments for each row in our News screen.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
struct Badge: View { let text: String let imageName: String var body: some View { HStack { Image(systemName: imageName) Text(text) } } } struct Position: View { let position: Int var body: some View { ZStack { Circle() .frame(width: 32.0, height: 32.0) .foregroundColor(.teal) Text("\(position)") .font(.callout) .bold() .foregroundColor(.white) } } } struct NewsView_Previews: PreviewProvider { static var previews: some View { Group { Position(position: 1) Badge(text: "1.234", imageName: "paperplane") } .previewLayout(.sizeThatFits) } } |
I could have used the Label
type of SwiftUI instead of creating the Badge
view, but the former has a spacing that does not fit my mockup.
What is important here is that our two views are entirely independent of our Item
structure. Instead, they use simple Swift types like Int
and String
. This is an excellent practice to keep your types as loosely coupled as possible so that changes break as little code as possible.
Formatting code should be kept out of views and view models
The view for a single story also looks straightforward. But here we have to pause for a moment.
Our UI requires a lot of data formatting:
- The numbers for upvotes and comments need decimal separators;
- We only need to show the domain name for a story, not its full URL;
- A story doesn’t have a date but shows how much time has passed since its submission.
Formatting is related to the visual representation of data, so its code does not belong to the Item
structure.
At the same time, we want to keep views independent and reusable by using simple Swift types. So, where do we put our formatting code?
Some proponents of MVVM think that formatting code should go into view models. But that’s a mistake.
First of all, this would overload view models with responsibilities. Moreover, the formatting code needs to be reusable and not tied to a single view or view model.
While we only have a single screen in our sample app, that rarely happens. A separate screen showing each story’s comments, as it happens on the Hacker News website, would need to format data in the same way.
Luckily, Swift offers a perfect solution: extensions.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
extension URL { var formatted: String { (host ?? "").replacingOccurrences(of: "www.", with: "") } } extension Int { var formatted: String { let formatter = NumberFormatter() formatter.numberStyle = .decimal return formatter.string(from: NSNumber(value: self)) ?? "" } } extension Date { var timeAgo: String { let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .short return formatter.localizedString(for: self, relativeTo: Date()) } } |
Don’t follow the MVVM pattern too strictly
Now, I have a question for you.
To which layer of MVVM does the formatting code above belong?
To be honest, I don’t think that’s a question that makes much sense. This shows you the dangers of adhering too strictly to a particular design pattern.
It looks like the code above belongs to the model layer since we are extending data types like Int
, URL
, and Date
. At the same time, our extensions do not affect those types. We own that code, and we only use it in the view layer.
We can even place it inside the same files as our views. If I had to find a definite answer, I would say that our extensions are still part of the view layer.
Which brings me to the next point.
Keeping the view and model layers decoupled using Swift extensions
This could be a possible implementation for the view representing a story.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
struct Story: View { let position: Int let item: Item var body: some View { HStack(alignment: .top, spacing: 16.0) { Position(position: position) VStack(alignment: .leading, spacing: 8.0) { Text(item.title) .font(.headline) Text(footnote) .font(.footnote) .foregroundColor(.secondary) ZStack(alignment: Alignment(horizontal: .leading, vertical: .center)) { Badge(text: item.score.formatted, imageName: "arrowtriangle.up.circle") .foregroundColor(.teal) Badge(text: item.commentCount.formatted, imageName: "ellipses.bubble") .padding(.leading, 96.0) .foregroundColor(.orange) } .font(.callout) .padding(.bottom) } } .padding(.top, 16.0) } var footnote: String { item.url.formatted + " - \(item.date.timeAgo)" + " - by \(item.author)" } } |
This view is coupled with our Item
structure.
In a simple app, I would consider that to be acceptable. There is no need to complicate your code and keep it as generic as possible when that’s not needed.
In more complex apps, though, you often need to reuse UI components with different data types. Moreover, the Story
view above embeds formatting code that might not apply to all data.
In that case, we need to decouple the Story
view from the Item
model type.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
struct Story: View { let position: Int let title: String let footnote: String let score: String let commentCount: String var body: some View { HStack(alignment: .top, spacing: 16.0) { Position(position: position) VStack(alignment: .leading, spacing: 8.0) { Text(title) .font(.headline) Text(footnote) .font(.footnote) .foregroundColor(.secondary) ZStack(alignment: Alignment(horizontal: .leading, vertical: .center)) { Badge(text: score, imageName: "arrowtriangle.up.circle") .foregroundColor(.teal) Badge(text: commentCount, imageName: "ellipses.bubble") .padding(.leading, 96.0) .foregroundColor(.orange) } .font(.callout) .padding(.bottom) } } .padding(.top, 16.0) } } |
This does not solve our problem, though. It only moves it up the view hierarchy. The view that contains the Story
type will need to use the code we just removed.
Here, Swift extensions come to our rescue again.
1 2 3 4 5 6 7 8 9 10 11 |
extension Story { init(position: Int, item: Item) { self.position = position title = item.title score = item.score.formatted commentCount = item.commentCount.formatted footnote = item.url.formatted + " - \(item.date.timeAgo)" + " - by \(item.author)" } } |
We can now initialize our view using an Item
value, while its internal code uses only simple Swift types.
We can even export our Story
view to another app without the Item
type. All we need is a new extension using the model types of the destination project.
We can now use this new initializer in two places. The first is inside the SwiftUI preview.
1 2 3 4 5 6 7 8 9 10 |
struct NewsView_Previews: PreviewProvider { static var previews: some View { Group { Story(position: 1, item: TestData.story) Position(position: 1) Badge(text: "1.234", imageName: "paperplane") } .previewLayout(.sizeThatFits) } } |
The second is in the view for the whole News screen.
1 2 3 4 5 6 7 8 9 10 |
struct NewsView: View { let stories: [Item] var body: some View { List(stories.indices) { index in Story(position: index + 1, item: stories[index]) } .navigationTitle("News") } } |
Chapter 4:
The networking infrastructure of an MVVM app


Networking is another of the weak points of the standard approach to MVVM. Many developers put networking code inside view models. It’s better to keep such code separate to make it extensible and avoid repetition.
Should networking code go into view models?
Our app needs to fetch data through the Hacker News API. Networking code clearly does not go into either the model or the view layers.
This is another long-standing debate about MVVM, where some proponents insist that networking code goes inside a view model?
I beg to differ.
If you look at it from the point of view of reusing code, that’s a mistake. While a view model clearly needs to trigger network requests to fetch data, networking code is filled with boilerplate.
That’s code that is clearly going to be repeated in every view model that performs network requests.
Here, I think the culprit is the use of reactive frameworks like RxSwift and Combine. All that boilerplate code gets lost into the glue code that connects streams of data.
But that’s just replacing some boilerplate with some other boilerplate.
Creating a separate networking layer to keep boilerplate code out of view models
A better approach to iOS networking in Swift is to build a separate infrastructure for API requests.
Our sample app doesn’t need a networking hierarchy as complex as the one in that article. Still, we can use a generic network request class, which simplifies our view model code and is easy to extend.
To fetch the best stories from the API, we need to perform two types of requests.
- Usually, REST APIs return arrays of JSON objects with the full data. The best stories endpoint, instead, only returns an array of IDs.
- After getting those IDs, we need to call the story endpoint several times to fetch each story’s data.
So, our API request class needs to handle different types of JSON data.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
class APIRequest { let url: URL init(url: URL) { self.url = url } func perform<T: Decodable>(with completion: @escaping (T?) -> Void) { let session = URLSession(configuration: .default, delegate: nil, delegateQueue: .main) let task = session.dataTask(with: url) { (data, _, _) in guard let data = data else { completion(nil) return } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .secondsSince1970 completion(try? decoder.decode(T.self, from: data)) } task.resume() } } |
In short, the perform(with:)
method uses a URLSession
to fetch data and then decodes it with a JSONDecoder
. Since it uses a Swift generic, we can use this method for any Decodable
data.
Again, refer to the article about REST APIs I linked above for a full explanation.
Chapter 5:
Bringing an app together using view models


The core piece in the MVVM pattern is the view model layer. This brings together all the other parts of an app’s architecture, connecting views to data and the networking infrastructure.
A view model contains the app’s business logic that drives a single app screen
We can finally focus on the core layer of MVVM: the view model layer.
A view model is limited only to the logic that drives a specific screen in an app. In our example, we only need one for the NewsView
type, representing the whole News screen.
Our view model needs to perform all the network requests to retrieve the top 10 best stories from Hacker News.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
class NewsViewModel: ObservableObject { @Published var stories: [Item?] = Array(repeating: nil, count: 10) func fetchTopStories() { let url = URL(string: "https://hacker-news.firebaseio.com/v0/beststories.json")! let request = APIRequest(url: url) request.perform { [weak self] (ids: [Int]?) -> Void in guard let ids = ids?.prefix(10) else { return } for (index, id) in ids.enumerated() { self?.fetchStory(withID: id) { story in self?.stories[index] = story } } } } func fetchStory(withID id: Int, completion: @escaping (Item?) -> Void) { let url = URL(string: "https://hacker-news.firebaseio.com/v0/item/\(id).json")! let request = APIRequest(url: url) request.perform(with: completion) } } |
First of all, the NewsViewModel
class conforms to the ObservableObject
protocol and publishes its stories
property using the @Published
property wrapper. This is the “binder” part of MVVM we discussed at the beginning of the article.
While the NewsViewModel
class contains the logic to retrieve the stories, it does not know anything about the NewsView
. This leaves the view model decoupled from its view, making it easier to write unit tests.
I divided the fetching of data into two methods to avoid callback hell. This is the simplest way to achieve that without using a reactive framework like Combine.
The fetchTopStories()
method fetches the IDs of the best stories. Then, it calls the fetchStory(withID:completion:)
method for each of these. This, in turn, fetches the details of each story.
All these requests happen in parallel, and their callbacks might occur in any order. Appending the stories to the stories
array might then change their order.
To avoid complex synchronization code, I first populate the stories
array with 10 nil
values and replace them with the corresponding story using indexes.
Creating view models in SwiftUI views and triggering events
All we have left is connecting our view to its view model.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
struct NewsView: View { @StateObject private var model = NewsViewModel() var body: some View { List(model.stories.indices) { index in if let story = model.stories[index] { Story(position: index + 1, item: story) } } .navigationTitle("News") .onAppear(perform: model.fetchTopStories) } } |
The NewsView
type creates the NewsViewModel
instance using the @StateObject
property wrapper. Then, it triggers the network requests by calling the fetchTopStories()
method in its .onAppear
modifier.
The @ObservedObject
wrapper would be a mistake. The view would recreate its view model every at every refresh of the view hierarchy, with the effect of losing the callbacks of all the network requests in progress.
We could also use the @EnvironmentObject
wrapper in this specific case, but that’s not a common practice. View models often need to be initialized with particular values, which is only possible in a view’s initializer.
The @EnvironmentObject
is more appropriate for shared instances of global objects. The ones that are usually called controllers in the MVC pattern.
To complete our app, we only need to add a navigation view to our app’s entry point.
1 2 3 4 5 6 7 8 9 |
struct HackerNewsApp: App { var body: some Scene { WindowGroup { NavigationView { NewsView() } } } } |
Conclusions
The MVVM pattern provides a useful architectural idea: the views in an iOS app often need dedicated objects. These are the view models of MVVM.
In this article, we have seen one of the most common uses for view models: performing a sequence of network requests to fetch data for a single view.
We also saw how to solve many of the problems of sticking to the pattern definition too strictly. Making formatting and networking code reusable simplifies all the view models in an app.
There is only so much this article can cover about an app’s full architecture. As I mentioned, complex apps often need global objects.
The MVVM pattern alone is too limited. Trying to shoehorn all your code into view models is only going to create problems.
There are also some problems that a strict approach to MVVM does not address. For example, why does the NavigationView
in the example above go into the main app structure? And what happens in apps with tricky navigation?
Moreover, adding objects to views breaks Xcode previews. After adding the NewsViewModel
instance to the NewsView
, you will notice that its preview becomes empty.
To solve all these problems, we need an extra layer, which I call the root layer. To know more about it, get my free guide below.
Architecting SwiftUI apps with MVC and MVVM




It’s easy to make an app by throwing some code together. But without best practices and robust architecture, you soon end up with unmanageable spaghetti code. In this guide I'll show you how to properly structure SwiftUI apps.