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.
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.
Thanks very much for these posts. I'll collect all my questions here since the comments are limited:
call objectWillChange.send()
, is this replacing @Published? I.e: We call objectWillChange.send(), then SwiftUI will redraw the view, and automatically get the new contents when it re-reads Folder.folders?shared
static vars? Or otherwise how is this generally done? How does the Active Record actually access the objects that it needs to via those static vars? If you want to swap implementations, for testing purposes or maybe because you actually need different implementations at runtime, how is this usually handled?SwiftUI is really interesting and I'm finding it much easier to develop out new features. Learning about these patterns is super helpful, so thanks for taking the time to engage!
1 - Active Record (like Data Mapper or Repository) is a pattern, very popular, for data access, we use where needed, I use some example to show that model is not only property’s structs. For a library you can use Product, Order as active record but for an app you should include a ProductStore (state), OrderStore (state)… also you can implement all Product, Order tasks on Store and not use Product, Order as active record at all. Remember: typically a framework, like StoreKit 2, need to be flexible, more stateless. A framework is used by an app. For an app like SwiftUI you need one or more state objects, stateful.
2 - Yes, objectWillChange.send() do the @Published job, also @Published is a convenience for objectWillChange.send() and use it. See 2019 post about this, initially (SwiftUI betas)we don’t have @Published. Forget in last posts, you should call objectWillChange.send() before you change the property, not after, be careful with asyncs stuffs.
@Published var selectedFolder: Folder? = nil
=
var selectedFolder: Folder? = nil {
willSet {
objectWillChange.send()
}
}
3 - Yes!
4 - As I said in 1 point, you can handle everything on ObservableObject and keep only property structs, and just add some needed tasks to structs.
struct Product {
// Properties (data)
// Tasks
func purchase() async throws { ... }
// Factory Method
static products: [Self] {
get async throws {
return try await WebService.shared.request(…)
}
}
}
class ProductStore: ObservableObject {
@Published var products: [Product] = []
func load() async {
do {
products = try await Product.products
} catch {
// handle error
}
}
}
or
struct Product {
// Properties (data)
// Tasks
func purchase() async throws { ... }
}
class ProductStore: ObservableObject {
@Published var products: [Product] = []
func load() async {
do {
products = try await WebService.shared.request(…)
} catch {
// handle error
}
}
}
or
struct Product {
// Properties (data)
}
class ProductStore: ObservableObject {
@Published var products: [Product] = []
func purchase(_ product: Product) async throws { ... }
func load() async {
do {
products = try await WebService.shared.request(…)
} catch {
// handle error
}
}
}
5 - In active record you use static func / vars for “Factory Method”, not single instance. For this pattern you only use shared (single instance) for your service / provider objects (e.g. WebService.shared.request(…)). And in general for SwiftUI you should avoid singleinstance for non service / provider objects, use @EnvironmentObject.
Thank you!
ObservableObject is a working object where we aggregate related data and tasks.
We see ObservableObject as:
ObservableObject (working, in-memory data) from:
Example: My last app (multiplatform) have in-app purchases (use StoreKit 2), I have a SubscriptionStore (ObservableObject) for:
I can add purchase task to SubscriptionStore but use product.purchase() from StoreKit 2 in View. As I use this object in different views I use @EnvironmentObject to have one instance and access from any view in hierarchy.
The app use 2 data access models based on Active Record, the my data (web service) model (part active record, part handle some things in the stores) and the StoreKit 2 model.
Remember: Active Record is about data access, not state. SwiftUI views need a state, local (@State) and external (@StateObject).
Imagine the CoreLocation 2 using await / async, no delegates. Now we use CLLocation (property only struct) and CLLocationManager (object). In future we could use Location as Active Record:
Also, how the long wait SwiftData (Core Data next generation) be like:
We have Active Record for data access in PHP Laravel database and Swift server-side Vapor (Fluent database).
Why doesn't Apple just come out and say if they designed SwiftUI to use MVVM or MV?
Easy, the MVVM comes from old technology. Also Microsoft, who sell MVVM from 2005 is not using it on new UI framework (declarative). SwiftUI is new tech, modern, declarative. They eliminate the middle layer. Is part of evolution, simplicity. We only need a View layer and a Model layer. Inside Model layer we can do what we want (also some VMs I see on blogs and tutorials are state / store, part of the model). We can’t use MVVM for SwiftUI without problems and limitations.
Talking and wanting MVVM or MVC today is the same wanting C++ or Pascal. No one want go back! There’s a modern and more easy techs.
The evolution:
SwiftUI automatically performs most of the work traditionally done by view controllers. Fact: SwiftUI View is a ViewModel.
To remember! The model layer are not (and never was) only property structs. The model layer is our data, services / networking, state, business objects, processors, …
Many devs don’t understand the MVC / MVVM. Fact: VM (reactive) = C (imperative)
Well you just move business logic from vm(c) to v and m. Mostly in m (with active record). So now we have fat model and some logic in view? What benefits?
It's obvious - with SwiftUI we should drop the C from MVC, but still we should keep the business logic somewhere.
Let's try to reason where is the best place? (We are developers working on a moderate iOS project - not a small one, but not something huge.)
in the view? - No. (Too many reasons to avoid it - complex views, unreadable views, hard to unit test, etc.)
in the model - Probably.
in the controller - No. There is no controller in SwiftUI.
Otherwise we need some place which we can call whatever we want. (Some people prefer to use "ViewModel". Please, don't mess this with MVVM design pattern.).
Another generic problem that we should solve - caching (because we fetch some data from a server and we store it on the device, while the app is running, or even persist it between different sessions.) Also this data may be updated from time to time based on other users (imagine a chat functionality in an app).
Who should be responsible for storing that data? It's not the view. It's not the controller. Guess, who? - The model.
Well, if we continue the same way, the model is not anymore a typical model (closer to POJO) - it's growing and getting wiser and powerful (and massive :D). Which leads to some sort of separation if we want to make our codebase maintainable.
In summary - if the project you are working is growing then you have to introduce something that will make it easier to grow. It's up to you to pick the right tools.
MVVM or something similar might be an overkill in the beginning of any project. But at some point partially using any architectural Design Pattern (or just the good bits) will solve a lot of problems.
Yes everything will be in model and model should not be POJOs. Inside model we can separate the things like Data, Network, Database, State / Stores. We have many patterns for data access (active record, data mapper, repository, …). You can call the stores / state as ViewModels and not using MVVM. The “MVVM pattern” becomes problematic (and unnecessary) with declarative UI frameworks (SwiftUI, Flutter, React, …).
Currently I’m working on big SwiftUI project, multi model, and I can tell “Active Record pattern” (data access) and “Store pattern” (state, source of truth) + @EnvironmentObject are our best friends.
Another generic problem that we should solve - caching (because we fetch some data from a server and we store it on the device, while the app is running, or even persist it between different sessions.) Also this data may be updated from time to time based on other users (imagine a chat functionality in an app). Who should be responsible for storing that data? It's not the view. It's not the controller. Guess, who? - The model.
Yes 🙌
Finished an app with caching (local data sync from remote), our model have few “stores” as source of truth to do that job, and again EnvironmentObject is our best friend.
Another Example
We can have a single store for everything (not recommended for big, multi section / tab apps) or multi stores, separated by use case / data type / section / tab. Again, stores (ObservableObjects), we can call other names, are part of the model (yes we can separate from our data model). Also we can do everything in stores and not use active record pattern at all but my last experience tell me that is good to separate our data model (using a data access pattern) from state (we can call store object, business object, use case object, state object, external source of truth object, …).
This is the SwiftUI (declarative UI) approach. With MVVM pattern:
Who needs a ViewModel today?
Little, simple and clean code. Everything works great on iOS, iPadOS, macOS, tvOS, Unit / Integration Tests server. Very productive team with well defined responsabilities. Note: Just an example based on real SwiftUI app.
Hello and thank you for this awesome thread. I agree with you on this, and SwiftUI is a completely new way of thinking for iOS. It's like when you are developing using ECS instead of OOP. And again, it depends your app and/or feature. You are not limited to one-exclusive pattern or achitecture for your app.
I have some questions about this, and your last post is perfect as an example.
With small data set you can have favorite channels in your Account object. Also you can remove subscription plans from Account and have a SubscriptionStore. We do what makes sense for our business. But just remember: ObservableObject is a Source of Truth, part of our model, not a ViewModel.
If the user wants to favorite channels and VOD movies, how would you handle that?
1- A list of [Channel]
and [Movie]
in your AccountStore
(user state/data) ?
2- Or if we want to keep only the ID (eg. [Channel.ID]
+ [Movie.ID]),
we need to map these ids to their object after app launch.
Is it the Account that access the data? or do you have a nested dependency to access the data only via ChannelStore
and VODStore
(requiring these stores load the data before to not have empty collection)
ChannelStore.load
VideoOnDemand.load
Account.load + map Channel/VOD.ID.
3- Can different store access the same underlying data via your Active record object? or if a View needs data from 2 different store to map them to a new object type.
4- Also curious about an active record example. what is inside these "..." in the static methods or functions.
Movie.all { ??? }
do you always call Webservice.shared.allMovies? are you using a cache? return Cacheservice.shared.allMovie ?? Webservice.shared.allmovies
thank you!
Simple question: what if the data format in your model does not correspond with how you want to show it on screen? E.g. it's not simply a String
in your model that you show in a TextField
component, but you need to split it over 3 different TextField
components and merge it when you put it back in the model?
Unless I am mistaken I haven't seen one word about converting data.
Model vs Form (View)
What if the data format in your model does not correspond with how you want to show it on screen? Depends on your needs. From my experience I find using local state (1.2) for form data, then convert to your model the best approach. Form should follow the Model. Remember View = f(Model).
struct RegistrationInfo: Codable {
var name: String = ""
var email: String = ""
var phone: String = ""
var age: Int = 19
func submit() async throws { ... }
}
// Convenience if needed
extension RegistrationInfo {
var firstName: String { String(name.split(separator: " ").first ?? "") }
var lastName: String { String(name.split(separator: " ").last ?? "") }
}
// 1.1 - Using view local state (direct), need a tricky solution
struct RegistrationForm: View {
@State private var info = RegistrationInfo()
@State private var isSubmitting: Bool = false
@State private var submitError: Error? = nil
var body: some View {
Form {
Section("Name") {
TextField("First name", text: Binding(get: { info.firstName }, set: { value, _ in ???? }))
TextField("Last name", text: Binding(get: { info.lastName }, set: { value, _ in ???? }))
}
// ...
Button("Submit") {
Task {
isSubmitting = true
do {
try await info.submit()
} catch {
submitError = error
}
isSubmitting = false
}
}
}
}
}
// 1.2 - Using view local state (indirect)
struct RegistrationForm: View {
@State private var firstName: String = ""
@State private var lastName: String = ""
@State private var email: String = ""
@State private var phone: String = ""
@State private var age: Int = 18
@State private var isSubmitting: Bool = false
@State private var submitError: Error? = nil
var body: some View {
Form {
Section("Name") {
TextField("First name", text: $firstName)
TextField("Last name", text: $lastName)
}
// ...
Button("Submit") {
Task {
isSubmitting = true
do {
let info = RegistrationInfo(name: "\(firstName) \(lastName)",
email: email,
phone: phone,
age: age)
try await data.submit()
} catch {
submitError = error
}
isSubmitting = false
}
}
}
}
}
// 2 - Using an external state, object part of your model
class Registration: ObservableObject {
@Published var firstName: String = ""
@Published var lastName: String = ""
@Published var email: String = ""
@Published var phone: String = ""
@Published var age: Int = 18
@Published var isSubmitting: Bool = false
var submitError: Error? = nil
func finish() async {
isSubmitting = true
do {
let data = RegistrationInfo(name: "\(firstName) \(lastName)",
email: email,
phone: phone,
age: age)
try await data.submit()
} catch {
submitError = error
}
isSubmitting = false
}
}
struct RegistrationForm: View {
@State private var registration = Registration()
var body: some View {
Form {
Section("Name") {
TextField("First name", text: $registration.firstName)
TextField("Last name", text: $registration.lastName)
}
// ...
Button("Submit") {
Task {
await registration.finish()
}
}
}
}
}
Your Registration
class in example 2 is what most people would probably implement as RegistrationViewModel
.
I'm not 100% with you on the "all ViewModels must die" train yet, but I do agree it's completely useless in a lot of scenarios. I started thinking about it a bit more and in my current project I just deleted/refactored a whole bunch of files because of this, because I realised they didn't bring anything to the table except for boilerplate code.
Registration, ChannelStore, MovieStore, Account… are not ViewModels, but in some cases can feel like. MVVM (old tech) requires a middle layer and one ViewModel for each View. ViewModel is the state and handle tasks for that View. In my Movies example if you use MVVM you will implement a MovieListVM, MovieRowVM, MovieGridVM, MovieCardVM. SwiftUI View is a ViewModel, have local state, you can handle events inside, … you only need to give to View the Source of Truth(s). I only have a MovieStore.
Registration and other objects are Source of Truths, model objects, part of our model. That objects are independent from a specific view and this is one important difference from MVVM. I see, from other platforms some people use the name VM for the “Source of Truth” (shared, view independent, …) but this is not MVVM. Also we can’t share a state using true MVVM, for SwiftUI (and other declarative platforms) EnvironmentObject is key for many use cases and situations.
Channels
There’s many ways for it, depending on our needs. Just some ideas.
Model layer - Example 1
struct Channel: Identifiable, Hashable, Codable {
let id: String
// Data
var isFavorite: Bool
// Factory Methods
static var channels: [Self] {
get async throws {
try await MyTVWS.shared.request(resource: "channels",
verb: .get)
}
}
}
class ChannelStore: ObservableObject {
@Published var channels: [Channel] = []
var favorites: [Channel] { channels.filter { $0.isFavorite } }
@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
}
}
Model layer - Example 2
Check if channel is favorite on the “favorites” store.
struct Channel: Identifiable, Hashable, Codable {
let id: String
// Data
func addToFavorites() async throws { ... }
func removeFromFavorites() async throws { ... }
// Factory Methods
static var channels: [Self] {
get async throws {
try await MyTVWS.shared.request(resource: "channels",
verb: .get)
}
}
static var favoriteChannels: [Self] {
get async throws {
try await MyTVWS.shared.request(resource: "favoriteChannels",
verb: .get)
}
}
}
// 2.1
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.channels
favoriteChannels = try await Channel.favoriteChannels
} catch {
loadError = error
}
isLoading = false
}
}
// 2.2
class ChannelStore: ObservableObject {
@Published var channels: [Channel] = []
@Published var isLoading: Bool = false
var loadError: Error? = nil
enum Source {
case all
case favorites
}
private let source: Source
init(_ source: Source) {
self.source = source
}
func load() async {
isLoading = true
do {
switch source {
case .all:
channels = try await Channel.channels
case.favorites:
channels = try await Channel.favoriteChannels
}
} catch {
loadError = error
}
isLoading = false
}
}
// 2.3
class ChannelStore: ObservableObject {
@Published var channels: [Channel] = []
@Published var isLoading: Bool = false
var loadError: Error? = nil
open func load() async { }
}
class AllChannelStore: ChannelStore {
func load() async {
isLoading = true
do {
channels = try await Channel.channels
} catch {
loadError = error
}
isLoading = false
}
}
class FavoriteChannelStore: ChannelStore {
func load() async {
isLoading = true
do {
channels = try await Channel.favoriteChannels
} catch {
loadError = error
}
isLoading = false
}
}
// 2.4
class ChannelStore: ObservableObject {
@Published var channels: [Channel] = []
@Published var isLoading: Bool = false
var loadError: Error? = nil
open func loadChannels() async throws { }
func load() async {
isLoading = true
do {
try await loadChannels()
} catch {
loadError = error
}
isLoading = false
}
}
class AllChannelStore: ChannelStore {
func loadChannels() async throws {
channels = try await Channel.channels
}
}
class FavoriteChannelStore: ChannelStore {
func loadChannels() async throws {
channels = try await Channel.favoriteChannels
}
}
View layer - Based on Example 1
struct ChannelList: View {
@EnvironmentObject private var channelStore: ChannelStore
enum Mode {
case all
case favorites
}
@State private var mode: Mode = .all
var body: some View {
VStack {
Picker("", selection: $mode) { ... }
.pickerStyle(.segmented)
ScrollView {
LazyVStack {
switch mode {
case .all:
ForEach(channelStore.channels) { channel in
ChannelCard(channel: channel)
}
case .favorites:
ForEach(channelStore.favoriteChannels) { channel in
ChannelCard(channel: channel)
}
}
}
}
}
}
}
struct ChannelCard: View {
var channel: Channel
var body: some View { ... }
}
struct ProgramList: View {
@EnvironmentObject private var channelStore: ChannelStore
var body: some View { ... }
}
struct LivePlayerView: View {
@EnvironmentObject private var channelStore: ChannelStore
var body: some View { ... }
}
Movies
Model layer
struct Movie: Identifiable, Hashable, Codable {
let id: String
// Data
enum Section: String, Codable {
case all
case featured
case favorites
case currentlyViewing
// ...
}
enum Genre: String, Identifiable, Codable, CaseIterable {
case action
case comedy
case drama
case terror
case animation
case science
case sports
case western
// ...
var id: Self { self }
}
// Factory Method
static func movies(pageNumber: Int = 1,
pageSize: Int = 30,
search: String? = nil,
section: Movie.Section = .all,
genres: [Movie.Genre] = [],
sort: String? = nil) async throws -> [Self] {
try await MyTVWS.shared.request(resource: "movies",
verb: .get,
queryString: ...)
}
// --- or ---
// (recommended)
struct FetchOptions {
var pageNumber: Int = 1
var pageSize: Int = 30
var search: String? = nil
var section: Section = .all
var genres: [Genre] = []
var sort: String? = nil
}
static func movies(_ options: FetchOptions) async throws -> [Self] {
try await MyTVWS.shared.request(resource: "movies",
verb: .get,
queryString: ...)
}
}
class MovieStore: ObservableObject {
@Published var movies: [Movie] = []
@Published var isLoading: Bool = false
var loadError: Error? = nil
var options: Movie.FetchOptions
init(_ options: Movie.FetchOptions) {
self.options = options
}
// Add convenience initializings if wanted
init(_ section: Movie.Section,
genre: Movie.Genre? = nil
limit: Int = 15) {
self.options = ...
}
func load() async {
isLoading = true
do {
movies = try await Movie.movies(options)
} catch {
loadError = error
}
isLoading = false
}
func loadNextPage() async { ... } // Infinite scrolling
}
View layer
struct MovieList: View {
enum Mode {
case home
case genres
}
@State private var mode: Mode = .home
var body: some View {
VStack {
Picker("", selection: $mode) { ... }
.pickerStyle(.segmented)
ScrollView {
LazyVStack {
switch mode {
case .home:
MovieRow(store: MovieStore(.featured))
MovieRow(store: MovieStore(.currentlyViewing))
MovieRow(store: MovieStore(.favorites))
case .genres:
ForEach(Movie.Genre.allCases) { genre in
MovieRow(store: MovieStore(.all, genre: genre))
}
}
}
}
}
}
}
// Each row can be limited to n items and have a "View all" button to push MovieGrid with all items
struct MovieRow: View {
@StateObject var store: MovieStore
var body: some View { ... }
}
struct MovieGrid: View {
@StateObject var store: MovieStore
var body: some View { ... }
}
struct MovieCard: View {
var movie: Movie
var body: some View { ... }
}
Brilliant 🤯
Thank you, very helpful. Especially enjoyed the big fat mess as the end 😂
1/N Thanks for writing this post! I have written many articles regarding SwiftUI and MVVM, I have written books and even created courses on SwiftUI and MVVM. But each time I implemented a solution in SwiftUI and MVVM I felt not comfortable. The main reason was that I was always fighting the framework. When you work on a large application using MVVM with SwiftUI and you want to share state then it becomes extremely hard.
2/N The main reason is that you cannot access EnvironmentObject inside the view model. Well, you can pass it using a constructor but then it becomes pain to do it again and again. And most of the time, you end up getting confused that who is managing the state. I ran into this scenario multiple times and I had to take alternate routes to resolve this problem. One thing to note is that React and Flutter (as original poster said) does not use MVVM pattern. React uses context or Redux and Flutter