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.

Yes it's fine for small indie apps. Putting logic in a Model is absolutely ridiculous to me having Observed and State Objects provided by Apple and simply ignoring them because you don't like ViewModel term is just crazy in my opinion. Whatever works for you mate, I like my code modular and testable.

ok so If I am getting correctly then Account will have code for validation, network call, caching, routing, etc...

// State (shared in view hierarchy), part of your model
class Account: ObservableObject {
    @Published var isLogged: Bool = false

    func signIn(email: String, password: String) async throws {
     // VALIDATION CODE ****1
     // NETWORK CALL (we will have shared network object) ****2
     // PERSISTENT (we will have shared persistent object) ****3
     // ROUTE TO NEXT SCREEN (we will have global navigator or screen router) ****4
}
}

What we are achieving by giving so much responsibility to Account model.

Instead can't we just go with simple with SignInViewModel, also Validation, Persistency and Routing will not be tight together in Account.

See what I am thinking, and let's justify which one is better.

class SignInViewModel {
    func signIn(email: String, password: String) async throws {
     // NETWORK CALL (we will have shared network object) ****2
}
}

// View
struct SignInView: View {
    @EnvironmentObject private var account: Account

    @State private var email: String
    @State private var password: String

    var body: some View { ... }

    // Call from button
    func signIn() {

        // WE CAN CALL VALIDATION UTILITY FROM HERE ****1

        Task {
            do {
                try await signInViewModel.signIn(email: email, password: password)

              // WE CAN CALL PERSISTENT UTILITY FROM HERE ****3

             // THEN ROUTER CAN ROUTE TO NEXT VIEW  ****4

            } catch {
                // Change error state
            }
        }
    }
}

Model layer

class MyWebService {
    static let shared = MyWebService()
    
    // URLSession instance / configuration
    // Environments (dev, qa, prod, test / mocks)
    // Requests and token management
    
    var token: String? = nil
    
    func request<T>(/* path, method, query, payload*/) async throws -> T { ... }
    
    func requestAccess(username: String, password: String) async throws { ... }
    func revokeAccess() async throws { ... }
}
// Other names: User, UserStore, CurrentUser, ...
class Account: ObservableObject {
    @Published var isLogged: Bool = false
    @Published var error: Error? = nil
    
    struct Profile {
        let name: String
        let avatar: URL?
    }
    
    @Published var profile: Profile? = nil
    
    enum SignInError: Error {
        case mandatoryEmail
        case invalidEmail
        case mandatoryPassword
        case userNotFound
        case userActivationNeeded
    }
    
    func signIn(email: String, password: String) async {
        // Validation
        if email.isEmpty {
            error = SignInError.mandatoryEmail
            return
        } else if email.isEmail {
            error = SignInError.invalidEmail
            return
        }
        
        if password.isEmpty {
            error = SignInError.mandatoryPassword
            return
        }
        
        // Submit
        do {
            try await MyWebService.shared.requestAccess(username: email,
                                                        password: password)
            isLogged = true
            error = nil
        } catch HTTPError.forbidden {
            error = SignInError.userActivationNeeded
        } catch HTTPError.notFound {
            error = SignInError.userNotFound
        } catch {
            self.error = error
        }
    }
    
    func signOut() async {
        // Ignore any error
        try? await MyWebService.shared.revokeAccess()
        isLogged = false
    }
    
    func loadProfile() async {
        do {
            profile = try await MyWebService.shared.request(...)
            error = nil
        } catch {
            self.error = error
        }
    }
}

View layer

@main
struct MyApp: App {
    @StateObject var account = Account()
    
    var body: some Scene {
        WindowGroup {
            Group {
                if account.isLogged {
                    TabView { ... }
                        .task {
                            async account.loadProfile()
                        }
                } else {
                    SignInView()
                }
            }
            .environmentObject(account)
        }
    }
}
struct SignInView: View {
    @EnvironmentObject var account: Account
    
    @State private var email: String = ""
    @State private var password: String = ""
    
    // Text fields, button, error exception
    var body: some View {
        ScrollView { ... }
    }
    
    func signIn() {
        Task {
            await account.signIn(email: email,
                                 password: password)
        }
    }
}

How some developers, obsessed by testability & SOLID principles, think “separation of concerns”, making something simple, ..complex, problematic, expensive.

Model layer

class MyWebService {
    static let shared = MyWebService
    
    // URLSession instance / configuration
    // JSONDecoder/Encoder configuration
    // Environments (dev, qa, prod, test / mocks)
    // Requests and token management
    
    var token: String? = nil
    
    func request<T: Codable>(/* path, method, query, payload*/) async throws -> T { ... }
    
    func requestAccess(username: String, password: String) async throws { ... }
    func revokeAccess() async throws { ... }
}
struct Product: Identifiable, Hashable, Codable {
    let id: Int
    let name: String
    let description: String
    let image: URL?
    let price: Double
    let inStock: Bool
}
class ProductStore: ObservableObject {
    @Published var products: [Product] = []
    
    enum Phase {
        case waiting
        case success
        case failure(Error)
    }
    
    @Published var phase: Phase? = nil
    
    func load() async {
        do {
            phase = .waiting
            products = try await MyWebService.shared.request(path: “products”)
            phase = .success
        } catch {
            phase = .failure(error)
        }
    }
}

View layer

struct ProductList: View {
    @StateObject var store = ProductStore()

    var body: some View {
        List { ... }
            .task {
                await store.load()
            }
            .navigationDestination(for: Product.self) { product in
                ProductView(product: product)
                // (--- OPTIONAL ---)
                // Only if you need to make changes to the store and that store its not already shared
                // ProductView(product: product)
                //    .environmentObject(store)
                // (--- OR ---)
                // ProductView(product: product, store: store)
            }
    }
}
struct ProductView: View {
    var product: Product
    // (--- OR ---)
    // If you make changes to the product
    // @State var product: Product    

    // (--- OPTIONAL ---)
    // Only if you need to make changes to the store and that store its not already shared
    // @EnvironmentObject private var store: ProductStore
    // (--- OR ---)
    // @ObservedObject var store: ProductStore
    
    var body: some View {
        ScrollView { ... }
    }
}

Let me try to explain what is over engineering looks like for a following problem statement: Problem Statement: Print "Hello World" 5 times.

// Technique 1 :  Can be written by a naive developer, but that is fine, code is functional
print("Hello World")
print("Hello World")
print("Hello World")
print("Hello World")
print("Hello World")

// Technique 2 :  Better, here developer followed DRY principle ie Don't repeat yourself
for (i=1;i<=5;i=i+1) {
    print("Hello World")
}

// Technique 3 : Much better, along with DRY developer kept in mind for new changes that should be done easily
#define SIZE 5 
for (i=1;i<=SIZE;i=i+1) {
    print("Hello World")
}

// Technique 4 : over engineering started, well in this case developer don't need to recompile the code when changes are there for SIZE, just need to update number in the config file

int SIZE = ReadSizeFromAConfigurationFile("config")

for (i=1;i<=SIZE;i=i+1) {
    print("Hello World")
}

// Technique 5 : too much over engineering, now developer is reading that SIZE from the backend, again this has great flexibility, but you can see too many things have been involved here  

int SIZE = ReadSizeFromTheBackend("some_server_url")

for (i=1;i<=SIZE;i=i+1) {
    print("Hello World")
}

// Technique 6 : again too much over engineering, and developer is not following YAGNI principle, that's means requirement was saying print "Hello World", but developer written in a way that he/she can also change the string without any recompilation

int SIZE = ReadSizeFromTheBackend("some_server_url")
String s = ReadStringFromTheBackend("some_server_url")

for (i=1;i<=SIZE;i=i+1) {
    print("\(s)")
}

For naive developers technique 6 can be over engineered... but for someone else it can be functional + maintainable code, also there is no standard definition of over engineering code...

Let me try to explain what is over engineering looks like for a following problem statement: Problem Statement: Print "Hello World" 5 times.

// Technique 1 :  Can be written by a naive developer, but that is fine, code is functional
print("Hello World")
print("Hello World")
print("Hello World")
print("Hello World")
print("Hello World")

// Technique 2 :  Better, here developer followed DRY principle ie Don't repeat yourself
for (i=1;i<=5;i=i+1) {
    print("Hello World")
}

// Technique 3 : Much better, along with DRY developer kept in mind for new changes that should be done easily
#define SIZE 5 
for (i=1;i<=SIZE;i=i+1) {
    print("Hello World")
}

// Technique 4 : over engineering started, well in this case developer don't need to recompile the code when changes are there for SIZE, just need to update number in the config file

int SIZE = ReadSizeFromAConfigurationFile("config")

for (i=1;i<=SIZE;i=i+1) {
    print("Hello World")
}

// Technique 5 : too much over engineering, now developer is reading that SIZE from the backend, again this has great flexibility, but you can see too many things have been involved here  

int SIZE = ReadSizeFromTheBackend("some_server_url")

for (i=1;i<=SIZE;i=i+1) {
    print("Hello World")
}

// Technique 6 : again too much over engineering, and developer is not following YAGNI principle, that's means requirement was saying print "Hello World", but developer written in a way that he/she can also change the string without any recompilation

int SIZE = ReadSizeFromTheBackend("some_server_url")
String s = ReadStringFromTheBackend("some_server_url")

for (i=1;i<=SIZE;i=i+1) {
    print("\(s)")
}

For naive developers technique 6 can be over engineered... but for someone else it can be functional + maintainable code, also there is no standard definition of over engineering code...

KISS (Keep It Simple) vs SOLID in numbers

(real world project work hours from my team)

Team KISS

  • Development: 120 hours
  • Change #1: 16 hours
  • Change #2: 2 hours

Team SOLID

  • Development: +1500 hours
  • Change #1: 48 hours
  • Change #2: 14 hours

Everything becomes problematic, complex, unmanageable and expensive. (no other team members understand and want to fix / change)

These results are REAL and the company / managers are upset about the SOLID decision. This is very common in many teams and companies. KISS approach is more productive and easy to test, fix, change.

This is really nice that Team KISS did the project just in 120hrs (15 working days) and Team SOLID took 1500hrs ie around 6 months

  1. S:Single responsibility
  2. O:Open closed
  3. L:Liskov substitution
  4. I:Interface segregation
  5. D:Dependency Inversion

I am just wondering which part took that much time, well they must be using S for writing isolated component or modules, also O for adding new features, not sure about L and I, but D also must be there for inverting the dependency. Can you please elaborate?

Sorry but my personal opinion is only about developers must stop follow SOLID and “Uncle Bob” bad practices. The fact is, in world, SOLID projects (small, big) become complex, expensive and unmanageable, this is a fact, there are results / metrics that prove it.

You can see the difference on Apple, after Scott Forstall left everything becomes problematic, buggy and delayed. Scott Forstall was not an Agile / CI / CD / SOLID guy and everything platform (and iOS software) release was perfect! Just works! Every developer and iPhone user see the difference. There’s an iOS before Scott and another after. This is a fact!

Also, many don’t know but Agile / “Uncle Bod” approach just killed Microsoft. This is very well known during Windows Longhorn (Vista) development, everything becomes problematic, delayed and we never see some great features. The problems stills next years and they lose the smartphone battle. This is a fact!

Note: I think (and hope!) that Apple is not a fully (or obsessed) Agile / CI / CD / SOLID company, but new developer generation could bringing it on.

I love this perspective on MV and SwiftUI. I need more time to digest it to understand how I would fully integrate it with my own apps (all SwiftUI + MVVM). I'm struggling to see how you would recommend incorporating Core Data into an MV-based app using SwiftUI's features like @FetchedRequest. It seems like @FetchedRequest specifically puts the model directly in the view, leading to code bloat in the View. This is further complicated by Core Data requiring developers to use a Class (i.e., the NSManagedObject subclass entity) as the fetched object. Any thoughts? Thanks!

but if you have an app with many users, not all of them are on iOS 15. So you cant use task, async and await on i0S 14!

Thanks for the thread. As a newcomer to SwiftUI, though, I'm not sure how this would actually map in practice. For example, if I had a setup like this:

struct FileItem {
    var name: String
    var size: Int64

    static var all: [FileItem] = { ... }
    static var favourites: [FileItem] = { ... }

    func setAsFavourite(isFavourite: Bool) { ... }
}

It's the part that goes in those "..." that I'm having some trouble figuring out. I have experience with Android and I know how I'd go about it there: I'd have a Repository which queries the file system (and potentially caches the latest data), a view model which holds view-specific state (for example, maybe just the file items in a specific folder) and handles any sorting, filtering etc..., and a view which asks the view model to refresh the data on certain events, such as onResume. If async is needed, I'd just launch that in a viewModelScope inside the view model.

I'm not sure how to map this to SwiftUI with the model described in this post.

For example, if I did:

struct MyApp: App {
    @State var allItems = FileItem.all
    @State var favouriteItems = FileItem.favourites

    var body: some Scene {
        WindowGroup {
            NavigationView {
                MyListView(allItems) 
                MyListView(favouriteItems) 
            }
        }
    }
}
struct MyListView {
    @Binding var items: [FileItem]

    // Imagine there's a list here with buttons, and I can toggle the favourite status by tapping on the button for that item.

So what exactly should be happening if I call FileItem.setAsFavourite? Should it be adding a copy of the struct to the favourites array?

Would it make more sense to have things like "isFavourite" be instance vars, and then a collection like "favourites" would be a computed property? If so, would favourites still be a @State or otherwise how would the second list know that the value of the computed property changed? Would @State var favouriteItems = FileItem.favourites still work in that case?

How could collection updates be propagated? Would that look something like this:


async func syncCollectionWithFs() {
  // ... query FS
   var collection = query()
   MainActor.run { 
        FileItem.all = collection 
        FileItem.favourites =// Assume I'd map against some favourites DB to see what was marked as a favourite.
    }
}

But then how does this actually get propagated to the @States? By assigning the contents as a @State, didn't I already make a copy and updating the struct static vars isn't necessarily going to be published? Is this where ObservableObject would make more sense? I'm curious what the use case of the static funcs & vars on the structs are and how they are intended to be used.

How would this change if we make the collections async sequences or the functions async?

I think I'd know how I'd solve these questions if I was doing something more similar to Android with ViewModels and ObservableObjects, so these might seem like really basic questions, but as I said I'm coming from another universe, and I'm curious. ;) Thank you.

Hi, first if you are making a framework / library (stateless in many cases) your FileItem is perfect.

Active Record Pattern

struct FileItem {
    var name: String
    var size: Int64

    static var all: [FileItem] = { ... }
    static var favourites: [FileItem] = { ... }

    func setAsFavourite(isFavourite: Bool) { ... }
}

Repository Pattern

struct FileItem {
    var name: String
    var size: Int64
}

class FileItemRepository {
    func getAll() -> [FileItem] { ... }
    func getFavourites() -> [FileItem] { ... }

    func setFile(_ file: FileItem, asFavourite: Bool) { ... }
}

If you are making an App (stateful) you need a state. Think about single source of truth(s) and use ObservableObject for external state, you can have one or many state objects. All depends on your app needs but Keep It Simple. You can keep your FileItem with tasks or not, depends.

Example #1 (assuming favorite is a file attribute or a reference)

struct FileItem {
    var name: String
    var size: Int64

    func setAsFavourite(isFavourite: Bool) { ... }
}
class FileStore: ObservableObject {
    @Published var all: [FileItem] = []
    var favourites: [FileItem] { … } // filter favourites from all

    var isLoading: Bool = false // if needed
    var error: Error? = nil // if needed

    func load() async { … } // load all files, manage states (loading, error) if needed
}
struct MyApp: App {
    @StateObject var store = FileStore()

    var body: some Scene {
        WindowGroup {
            NavigationView {
                MyListView(.all)
                MyListView(.favourites)
            }
            .environmentObject(store)
            .task { await store.load() }
        }
    }
}

Example #2.1 (assuming favorite is another file)

struct FileItem {
    var name: String
    var size: Int64
}
class FileStore: ObservableObject {
    @Published var all: [FileItem] = []
    @Published var favourites: [FileItem] = []

    var isLoading: Bool = false // if needed
    var error: Error? = nil // if needed

    func load() async { … } // load all files and favourites files, manage states (loading, error) if needed

    func setFile(_ file: FileItem, asFavourite: Bool) { ... }
}
struct MyApp: App {
    @StateObject var store = FileStore()

    var body: some Scene {
        WindowGroup {
            NavigationView {
                MyListView(.all)
                MyListView(.favourites)
            }
            .environmentObject(store)
            .task { await store.load() }
        }
    }
}

Example #2.2 (assuming favorite is another file)

struct FileItem {
    var name: String
    var size: Int64
}
class FileStore: ObservableObject {
    @Published var files: [FileItem] = []
    
    enum Source {
        case all
        case favourites
    }

    var source: Source

    var isLoading: Bool = false // if needed
    var error: Error? = nil // if needed

    func load() async { … } // load all files or favourites files, manage states (loading, error) if needed

    func setFile(_ file: FileItem, asFavourite: Bool) { ... }
}
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            NavigationView {
                MyListView(FileStore(.all))
                MyListView(FileStore(.favourites))
            }
        }
    }
}

…or…

struct MyApp: App {
    @StateObject var allFileStore = FileStore(.all)
    @StateObject var favouriteFileStore = FileStore(.favourites)

    var body: some Scene {
        WindowGroup {
            NavigationView {
                MyListView()
                    .environmentObject(allFileStore)
                MyListView()
                    .environmentObject(favouriteFileStore)
            }
        }
    }
}

Example #2.3 (assuming favorite is another file)

struct FileItem {
    var name: String
    var size: Int64
}
class FileStore: ObservableObject {
    @Published var files: [FileItem] = []

    var isLoading: Bool = false // if needed
    var error: Error? = nil // if needed

    open func load() async { … } // to subclass

    func setFile(_ file: FileItem, asFavourite: Bool) { ... }
}
class AllFileStore: FileStore {
    open func load() async { … } // load all files, manage states (loading, error) if needed
}
class FavouritesFileStore: FileStore {
    open func load() async { … } // load favourites files, manage states (loading, error) if needed
}
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            NavigationView {
                MyListView(AllFileStore())
                MyListView(FavouriteFileStore())
            }
        }
    }
}

…or…

struct MyApp: App {
    @StateObject var allFileStore = AllFileStore()
    @StateObject var favouriteFileStore = FavouriteFileStore()

    var body: some Scene {
        WindowGroup {
            NavigationView {
                MyListView()
                    .environmentObject(allFileStore)
                MyListView()
                    .environmentObject(favouriteFileStore)
            }
        }
    }
}

Tips:

  • Don’t think ViewModel, think state (view independent) that is part of your model
  • You can have one or many ObservableObjects (external state)
  • Use ObservableObjects when needed and don’t forget view local state
  • EnvironmentObject is your best friend!
  • Keep It Simple

In my 3 professional / client SwiftUI apps I made, I learned that EnvironmentObject is critical for many situations. Also, many things become problematic (or impossible) if I think about ViewModels.

thanks for sharing this! Lately I was also starting to notice that MVVM is kinda forced into SwiftUI and ends up making a lot of boilerplate

anyway I need time to digest all of this since I’m a huge advocate of MVVM in UIKit

in the meantime can I ask you the link for the Apple slides? (I ve probably missed this session)

Stop using MVVM for SwiftUI
 
 
Q