Stop using MVVM for SwiftUI

Don’t over-engineering! No suggested architecture for SwiftUI, just MVC without the C.

On SwiftUI you get extra (or wrong) work and complexity for no benefits. Don’t fight the system.

Differences between MVVM and SwiftUI.

Difference between .NET View and SwiftUI View. Two different technologies means two different architecture approaches.

First of all, thank you Appeloper for all the effort you put on the whole post!

I love this kind of discussion because it always bring good questions and force us to remember why we use what we currently use. Another reason for being on your side is because some people treat the "Clean Architecture" as a bible that shouldn't be questioned and you are bringing those questions.

To be honest, I agree with a lot of things you showed us but I'm still skeptical on how those things would apply for a big project. Imagine an app that has hundreds of entities and complicated logics, wouldn't those translate to thousand lines of code in a single entity?

Just to be clear, I'm asking these questions with a totally open mind, as I said, I think you have something there and I'm trying to sell it in a new project we are building.

Some points that other people brought up:

  • Architectures should be technology agnostic, "MV" is pretty tied to how SwiftUI works.
  • MVVM is easier to implement and separate logic
  • Other arch like VIP/Clean are easier to test business logic and have more reusable components
  • The UI engineer from Apple told that this is a bad approach

Also you are not proposing a whole new architecture, just a way to build apps in SwiftUI.

Some people are very tied to Software Development as it was 50 years ago, with spec documents and boring stuff like that, but on most companies that's not how it works, it's us developers trying to build the next big thing and making it maintainable and testable.

Hi, posted something about that in older posts, for big projects you can have specific models (modules). You never end up with a big objects. I do large projects very easy to do and productive for the team. Scale very well and with clear (true) “separation of the concerns“.

The goal of architecture is to make your life (or your team’s life) easier. If this is not happening, then your architecture has failed.

…and sorry to repeat… Model != Entities

Model objects represent special knowledge and expertise. They hold an application’s data and define the logic that manipulates that data. The model is a class diagram containing the logic, data, algorithms, … of the software.

For example, you can think Apple Platform as a big project:

// Can be internal (folders inside project) or external (sub-modules, packages) if makes sense
// Independent and focused teamwork
// Easy to build, test and maintenance

Contacts (model)
ContactsUI

EventKit (the model)
EventKitUI

Message (the model)
MessageUI

CoreLocation (the model)
CoreLocationUI

Shared
SharedUI

[module]
[module]UI

Or a TV app:

User (model)
UserUI

LiveTV (the model)
LiveTVUI

VODMovies (the model)
VODMoviesUI

VODSeries (the model)
VODSeriesUI

Shared
SharedUI

Everyday I see many, small to big, Clean / VIP / VIPER projects with a massive Entities (…Interactors, Presenters, …) folders without a clear separation of the things where all the team members work on that (all things), creating problematic situations and complexity hell. Recently we watch the bad experience (NSSpain video) from SoundCloud team and how “Uncle Bod” things broke the promise.

I agree with @Appeloper. I started applying what has been said in this thread in a project I started from scratch and it makes a lot more sense. It's not perfect (but what is? Especially sometimes issues with threading and the use of @Published vars) but it makes the code a lot more compact, and I'm pretty sure that if I were to go through the code with @Appeloper there would be more gains made because I just arrived in the land of the "converts".

When you talk about big projects the use of "rigid" design patterns makes things even worse. I had the "luck" of working once in a VIPER code base. OMG. The amount of files is just staggering. If I see now that a company requires knowledge of Clean architecture / VIP / VIPER to work on their stuff I steer away. What a load of bloatware, hundreds and hundreds of files that don't really serve any purpose except for "adhering to the principles". In a way it becomes so "modular" that nobody understands what's going on anymore and lots of files just serve as "data passages" not doing anything useful. And most of the advantages of using this kind of architecture isn't even exploited (e.g. testability, but the entire codebase contains no tests. Or in theory you can swap out an interface layer for another, but let's be honest, how many projects actually have to do this? Especially if you write Swift, it's not as if you are going to plug a HTML frontend on your app all of the sudden).

I'd love if there was a GitHub repo anywhere with a simple version of the principles of this thread - I'm a .net dev moved over to iOS, our main app is mainly converted to SwiftUI but it's all MVVM, viewcontrollers, coordinators and some UIKIT bits so very hard to unpack without being a UIKIT dev previously

I have lots of SwiftUI gaps to fill so a sensible/simple structure to look at would be super useful!

I think you have many examples of it on web and WWDC sessions. There’s another guy leaving out MVVM, maybe he explain with some tutorials next months:

Another guy leaving out MVVM. Is testability really more important than using SwiftUl as intended? …sacrifice a lot of great SwiftUl's built in APIs. From my experience we don’t need VMs (MVVM) for testability or scalability.

Today many people talks about microservice (or modular) architectures. That concept are part of Apple platforms from 90s.

Thanks for this post @Appeloper. My team and me are reading through it and it's making us think a lot about our current practices and the architecture we are using.

There is something that is make me struggle a lot. With this approach your view have freedom to use one or more "Stores" if they need to, this is an important difference regarding MVVM where any view would have her own ViewModel, is this correct?

Then you could end up having something like this:

class AllChannelStore: ObservableObject {
    @Published var channels: [Channel] = []
    @Published var isLoading: Bool = false
    var loadError: Error? = nil

    func load() async {
        isLoading = true
        do {
            channels = try await Channel.channels
        } catch {
            loadError = error
        }
        isLoading = false
    }
}
​
class FavoriteChannelStore: ObservableObject {
    @Published var channels: [Channel] = []
    @Published var isLoading: Bool = false
    var loadError: Error? = nil

    func load() async {
        isLoading = true
        do {
            channels = try await Channel.favoriteChannels
        } catch {
            loadError = error
        }
        isLoading = false
    }
}
​
struct HomeChannelList: View {
    @StateObject private var allChannelStore = AllChannelStore()
    @StateObject private var favoriteChannelStore = FavoriteChannelStore()

    var body: some View {
        if ????isLoading???? {
            ProgressView()
        } else if ????isError???? {
            ErrorView()
        } else {
            ContentView()
        }
        .task {
            await allChannelStore.channels
            await favoriteChannelStore.channels
        }
    }
}

Here we have one view that uses two different stores as source of true since it has to show the Favorite channels, but also All channels. In this scenario how would you control the state the view is in (loading, success, failure, etc)?

My ideas are:

  • We could still have the state in each ObservableObject separately, but then we would need to add a bunch of logic after await favoriteChannelStore.channels to see if any of the two stores has given an error, or maybe (depending on the business logic) it could be okey to render only part of the view if one of the two hasn't failed.
  • Move everything to the view, so that she can control her own state. But I'm afraid that doing so you would end up with views that would controller their state and some other views that would have it controlled by the ObservableObject and I don't like this kind of inconsistencies.
  • Add another Store on top of Favorite channels and All channels store that would control this... which sounds a lot like going back to ViewModels

I have the feeling I'm missing something really basic here, and some insights on it would be really appreciated

Everything depends on your needs. The “store” works like the working memory. We load & aggregate the data we need (to use and manipulate) from different sources. It become the source of truth and can be used (and shared) by many “views”. React / Flutter / SwiftUI patterns are modern evolution of old patterns (e.g. MVVM, MVC, …).

Data

struct Channel: Identifiable, Codable {
    let id: String
    let name: String
    let genre: String
    let logo: URL?

    // Factory Methods
    static var all: [Self] { … }
    static var favorites: [Self] { … }
}

Example 1 - Handle individual sources

class ChannelStore: ObservableObject {
    @Published var channels: [Channel] = []

    @Published var isLoading: Bool = false
    var loadError: Error? = nil

    func loadAll() async {
        isLoading = true
        do {
            channels = try await Channel.all
        } catch {
            loadError = error
        }
        isLoading = false
    }

    func loadFavorites() async {
        isLoading = true
        do {
            channels = try await Channel.favorites
        } catch {
            loadError = error
        }
        isLoading = false
    }
}

Example 2 - Aggregate all related information

class ChannelStore: ObservableObject {
    @Published var channels: [Channel] = []
    @Published var favoriteChannels: [Channel] = []

    @Published var isLoading: Bool = false
    var loadError: Error? = nil

    func load() async {
        isLoading = true
        do {
            channels = try await Channel.all
            favoriteChannels = try await Channel.favorites
        } catch {
            loadError = error
        }
        isLoading = false
    }
}

Hi @Appeloper, I have some questions about the Store in this architecture called MV

  1. As I can see from the example above, your Store really looks like what they called View-Model in the MVVM architecture. From what I understand, the View-Model is exclusively belong to a specific View, while Store can be shared between multiple Views. Is there anything other differents or it's just that?

  2. If we mix UIKit and SwiftUI, I think the Store should be able to be shared with both kind of views, am I correct? I mean, if we use our plain old MVC, it's like your new idea about MV, where Model is still the same, and ViewController means View, and the Store should be able to shared between UIKit's ViewController and SwiftUI's View, right?

Hi Eddie,

  1. YES, correct and more… ViewModel also represents the presentation logic / state like “showConfirmationAlert”, “filterBy”, “sortBy”, “focus”, … In SwiftUI you can use @State properties for that. In MVVM the View state is binding to the ViewModel object. Store is more in “model” (or data) side.

  2. Yes you can use Store in UIKit (MVC) but remember that in Apple MVC the “Store” is what many of us call the “Model Controller”. (e.g. NSFetchedResultsController, NSArrayController, UIDocument, …).

In declarative platforms UI = f(State) (data-driven approach) you don’t need a middle man (controller, viewmodel, …). The ”Stores” are part of the model and works like the working memory, we use it to load / aggregate the data we need to handle (process, work, …).

I call “Store” (from React and WWDC videos) but we can call what we want and makes sense. We should just avoid to mix it with MVVM pattern.

Hi @Appeloper, I have question about MV architecture:

I have app with tabView (4 screens), one of them is called MenuView that contain all app features (more than 10). I want to follow MV architecture I need to declare all stores in menuView like this :

struct MenuView: View {
    @StateObject private var store1 = FeatureOneStore()
    @StateObject private var store2 = FeatureTwoStore()
    @StateObject private var store3 = FeatureThreeStore()
     ....
    
    // mode, ...
    
    var body: some View {
        NavigationStack(path: $navigation.path) {
             VStack {

             }
            .navigationDestination(for: ItemKey.self) { key in
                 // Push to features
            }
        }
        .environmentObject(store1)
        .environmentObject(store2)
        .environmentObject(store3)
     }
}

MenuView will end up with many stores, is that good ? or there is other solution

Hi @Appeloper, in your previous post, you wrote this:

struct Channel: Identifiable, Codable {
    let id: String
    let name: String
    let genre: String
    let logo: URL?

    // Factory Methods
    static var all: [Self] { … }
    static var favorites: [Self] { … }
}

My question is, what if you have multiple "source of truth", e.g. when offline, read from coredata, otherwise when online, read from firebase realtime db. If you write these logic inside the model (Channel), wouldn't it violate the purpose of the Model? Or maybe this is just an example, and in real case, you'd create another layer to retrieve the actual data? (Networking or offline storage)?

My concern is: In a decentralize environment, what if you have a model name ChatMessage, you can retrieve this model from different sources (different host urls, different api paths, local storage), how would you design your Model Object?

Hi Eddie,

Normally network data model != database (core data) model. You should have a separated data model and from my experience this save you from many problems. Also any networking cache should be done with default http, let the system do it, or custom saving the response on disk.

Keeping the some model data you can do this (using Active Record):

struct Channel: Identifiable, Codable {
    let id: String
    let name: String
    let genre: String
    let logo: URL?

    static func saveChannels(_ channels: [Self], on: …) async throws { … }

    // Factory Methods (set the data source object or protocol)
    static func all(on: …) async throws -> [Self] { … }
    static func favorites(on: …) async throws -> [Self] { … }
}

Using repository / manager pattern:

struct Channel: Identifiable, Codable {
    let id: String
    let name: String
    let genre: String
    let logo: URL?
}

protocol ChannelManager {
    static func loadAllChannels() async throws -> [Channel]
    static func loadFavoriteChannels() async throws -> [Channel]
    static func saveChannels(_ channels: [Channel]) async throws
}

struct NetworkChannelManager: ChannelManager {
    static func loadAllChannels() async throws -> [Channel] { … }
    static func loadFavoriteChannels() async throws -> [Channel] { … }
    static func saveChannels(_ channels: [Channel]) async throws { … }
}

struct LocalChannelManager: ChannelManager {
    static func loadAllChannels() async throws -> [Channel] { … }
    static func loadFavoriteChannels() async throws -> [Channel] { … }
    static func saveChannels(_ channels: [Channel]) async throws { … }
}

Example from Vapor platform:

Uses Active Record pattern for data access where you can set the data source.

// An example of Fluent's query API.
let planets = try await Planet.query(on: database)
    .filter(\.$type == .gasGiant)
    .sort(\.$name)
    .with(\.$star)
    .all()

// Fetches all planets.
let planets = try await Planet.query(on: database).all()

Hi @Appeloper,

Thank you very much for opening this amazing thread. I've read almost every post in this thread. I've never thought OOP and Model that deep before.

  • Thank you let me clearly know KISS vs SOLID.
  • Thank you let me clearly know the Data Access patterns: such as Active Record, Data Mapper, Repository, etc.
  • Thank you cleared my mind MV pattern vs MVC/MVVM/VIPER/VIP/MVI.
  • Thank you cleared my mind on how to design the Model layer (Data Objects + State Objects + Service Objects).

...

I really learned a lot from this thread. Amazing SwiftUI + MV pattern! Thank you very very much! Please keep rocking!

Software models are ways of expressing a software design, e.g. the Channel struct represents the model of a tv channel, the Program struct represents the model of a tv program, the folder / module / package LiveTV (that contains the Channel and Program structs) represents the model of a live tv system.

As said before, network data model != local database (core data) model. Also SwiftUI has a great integration with core data.

Hi @Appeloper, Im trying to use MV architecture in my project, below is a simple example of my code:

struct PaymentView: View {
    @StateObject private var store = PaymentStore()
    
    var body: some View {
        NavigationStack {
            PaymentCreditorListView()
            /* -> PaymentFormView() */
            /* -> PaymentUnpaidView() */
            /* -> PaymentConfirmationView() */
        }
        .environmentObject(store)
    }
}
class PaymentStore: ObservableObject {
     ....
    @Published var isLoading = false
    @Published var popup: Popup?
    
    private let service: PaymentService
    
    init(service: PaymentService = PaymentService()) {
        self.service = service
    }
    
    func getPaymentCreditors() async {
        do {
            isLoading = true
            let response = try await service.fetchPaymentCreditors()
            .....
            isLoading = false
        } catch {
            isLoading = false
            popup = .init(error: error)
        }
    }
    
    func getPaymentForm() async {
        do {
            isLoading = true
            let response = try await service.fetchPaymentForm()
            ....
            isLoading = false
        } catch {
            isLoading = false
            popup = .init(error: error)
        }
    }
    
    func getPaymentUnpaid() async {
        do {
            isLoading = true
            let response = try await service.fetchPaymentUnpaid()
            .....
            isLoading = false
        } catch {
            isLoading = false
            popup = .init(error: error)
        }
    }
}

On each view I use sheet to show popup error because sometimes I need to do something specific for that view (for ex: calling web service or redirection etc...)

.sheet(item: $store.popup) { popup in
    PopupView(popup: popup) 
}

The only problem I have right now is when one of the endpoints return an error, all the views that use the popups are triggred and I'm getting this warning message in the console "Attempt to present * on * which is already presenting...", same problem for progressLoader, it will fire all the other views.

Did I miss something with this approach ? or is there a better way to do it ?

Hi OuSS_90,

One fast way to fix the problema is to put the alert modifier on NavigationStack and not inside each view:

struct PaymentView: View {
    @StateObject private var store = PaymentStore()
    
    var body: some View {
        NavigationStack {
            PaymentCreditorListView()
            /* -> PaymentFormView() */
            /* -> PaymentUnpaidView() */
            /* -> PaymentConfirmationView() */
        }
        .sheet(item: $store.popup) { popup in
            PopupView(popup: popup) 
        }
        .environmentObject(store)
    }
}

But from your PaymentStore I think you are mixing different things or lazy load the store information. I don’t forget that store is about data not view (e.g. there’s an error info not a “popup”). Store should be as possible an data aggregator for some domain.

I don’t know the needs but here’s a example:

class PaymentStore: ObservableObject {
    @Published var isLoading = false
    @Published var loadError: Error? = nil
    
    private let service: PaymentService
    
    @Published var creditors: [Creditor] = []
    @Published var form: PaymentForm? = nil
    @Published var unpaid: ? = ? // Don’t know the type

    init(service: PaymentService = PaymentService()) {
        self.service = service
    }

    func load() async {
        isLoading = true
        do {
            let creatorsResponse = try await service.fetchPaymentCreditors()
            let formResponse = try await service.fetchPaymentForm()
            let unpaidResponse = try await service.fetchPaymentUnpaid()

            // jsonapi spec
            creators = creatorsResponse.data
            form = formResponse.data
            unpaid = unpaidResponse.data
        } catch {
            loadError = error
        }
        isLoading = false
    }
}

Or split the PaymentStore into CreditorStore, PaymentForm and UnpaidStore, all depends on use case and how data is handled.

Making a simple social network app from Apple WWDCs CoreData example, in this case the data model is defined in the backend.

The use case, what user can do in the system and the dependencies.

ERD of database and REST API endpoints

Now the data access model (API integration) in Swift. In case the data model defined in app you use the CoreData stack + objects and this is your model. Here you can do Unit / Integration tests.

In this case the data are external and you need a model to load (or aggregate) the data in memory and use it: PostStore and TagStore. In case of local data (using CoreData) you don’t need the stores, use the SwiftUI features.

Thank you @Appeloper for your reply,

store is about data not view (e.g. there’s an error info not a “popup”) : popup is just a struct that handle error & success messages

enum PopupType {
    case success
    case failure(Int)
    case info
    case warning
    case custom(String)
}

struct Popup: Identifiable {
    var id = UUID()
    var type: PopupType
    var title: String?
    var message: String?
    var closeText: String?
    var confirmText: String?
}
func load() async {
    isLoading = true
    do {
        let creatorsResponse = try await service.fetchPaymentCreditors()
        let formResponse = try await service.fetchPaymentForm()
        let unpaidResponse = try await service.fetchPaymentUnpaid()

        creators = creatorsResponse.data
        form = formResponse.data
        unpaid = unpaidResponse.data
    } catch {
        loadError = error
    }
    isLoading = false
}

I don't want to call all endpoints one after other, because I have three screens :

PaymentCreditorListView --> call fetchPaymentCreditors() and after user choose creditor I need to call fetchPaymentForm() that take creditor as parameter and then push to PaymentFormView (I need to save creditor to use it later in PaymentConfirmationView)

PaymentFormView --> When user press continue I need to call fetchPaymentUnpaid() that take form info as parameter and then push to PaymentUnpaidView() (I need to save form info & unpaid list to use it later in PaymentConfirmationView)

How can I handle this with their popups for each view using PaymentStore ? and if I need to split it as you said, we will not return to MVVM each view has his own store ?? because as soon as we have many endpoint, it become hard to handle each popup without firing others because they share same publisher

Also how can I handle push after endpoint finish if I can't add navigationPath inside store (because you said store have only Data)

Thank you

By design you can only had one alert at the time. Alerts are more for situations like user actions like send / submit, for the loading data cases you should use empty states. See other apps when a network error appear or no data loaded all at.

NavigationStack path is all about data, think data, not views. SwiftUI is data-driven nature.

Sometimes it feels we are doing MVVM but is different. In classic MVVM ViewModel (presentation logic) handle every View logic & state, each View have one ViewModel. The MVVM used in other platforms last years are not the true MVVM… sometimes ViewModel acts like a Store.

  • ViewModel -> Lives in a middle layer, presentation logic, a Controller with databinding.
  • Store -> Lives in a model layer, data logic

The problem I have is how I can handle errors/success for each view, because sometimes after error or success confirmation I need to do something different for some views. it will not work as I want if I add one sheet in root, let me give you an example:

In PaymentFormView I have button that display OTPView, after user enter code I call addToFavorite endpoint that get favoriteName from that view and if the code is wrong addToFavorite throw error, and when user confirm I need to display again OTPView and if it success I display success popup and after confirmation I need to pop to first view.

In PaymentConfirmView I have other scenario, I call submit endpoint and then I display success popup and after confirmation I need to push to other view

As you can see each view have a different staff to do after popup confirmation, If I add one sheet in root, is impossible to do this.

Is it a good idea to move do catch to view instead of store ??

class PaymentStore: ObservableObject {
    
    @Published var creditors: [Creditor] = []
    @Published var form: PaymentForm?
    @Published var unpaid: PaymentUnpaid?
    
    private let service: PaymentService
    
    init(service: PaymentService = PaymentService()) {
        self.service = service
    }
    
    func getPaymentCreditors() async throws {
        creditors = try await service.fetchPaymentCreditors()
    }
    
    func getPaymentForm() async throws {
        form = try await service.fetchPaymentForm()
    }
    
    func getPaymentUnpaid() async throws {
        unpaid = try await service.fetchPaymentUnpaid()
    }
}
struct PaymentCreditorListView: View {
    @EnvironmentObject private var store: PaymentStore
    @State private var idLoading = false
    @State private var popup: Popup?
   
    var body: some View {
        VStack {
        }
        .task {
            do {
            isLoading = true
            try await store.fetchPaymentCreditors()
            isLoading = false
        } catch {
            isLoading = false
            popup = .init(error: error)
        }
        .progress($isLoading)
    }
}

Hi OuSS_90,

For the case you have a single source of truth and progressive / lazy loading, you can have multiple errors (or popups) in the store. Example:

class PaymentStore: ObservableObject {
    @Published var creditors: [Creditor] = []
    @Published var form: PaymentForm? = nil
    @Published var unpaid: PaymentUnpaid? = nil

    @Published var isLoadingCreditors: Bool = false
    @Published var isLoadingForm: Bool = false
    @Published var isLoadingUnpaid: Bool = false

    @Published var creditorsError: Error? = nil // Popup
    @Published var formError: Error? = nil // Popup
    @Published var unpaidError: Error? = nil // Popup
    
    private let service: PaymentService
    
    init(service: PaymentService = PaymentService()) {
        self.service = service
    }
    
    func loadCreditors() async {
        creditorsError = nil
        isLoadingCreditors = true
        do {
            creditors = try await service.fetchPaymentCreditors()
        } catch {
            loadCreditorsError = error
        }
        isLoadingCreditors = false
    }

    func loadForm() async {
        formError = nil
        isLoadingForm = true
        do {
            form = try await service.fetchPaymentForm()
        } catch {
            loadFormError = error
        }
        isLoadingForm = false
    }

    func loadUnpaid() async {
        unpaidError = nil
        isLoadingUnpaid = true
        do {
            unpaid = try await service.fetchPaymentUnpaid()
        } catch {
            loadUnpaidError = error
        }
        isLoadingUnpaid = false
    }
}

Also you can have have an enum for load / error state:

enum State {
    case unloaded
    case loading
    case success
    case failure(Error)
}

…

@Published var creditorsState: State = .unloaded

Or some envelop for network data:

struct AsyncData<T> {
    var data: T
    var isLoading! bool = false
    var error: Error? = nil
}

…

@Published var creditors: AsyncData<[Creditor]>= AsyncData(data: [])

Stop using MVVM for SwiftUI
 
 
Q