SwiftUI - Decoupling Models

ยท

9 min read

Problem:

How can we avoid breaking an entire app when a model changes?

Solution:

Decouple each model based on their specific domain.


A Deeper Dive

When working with data from various sources, a common challenge developers face is the potential for entire sections of an app to break when there's a minor model change. A robust solution to this problem is decoupling models based on their specific domains.

Models

Here we will represent the user model in 3 different ways:

  1. UserApiV2 - Represents our data source's structure from an API.

  2. User - Acts as an intermediary and generic model, used in the entire app.

  3. UserViewData - Transforms and formats data for SwiftUI view representation.


Models definition

Let's get started creating the UserApiV2 will represent the API model

struct UserApiV2: Codable { // 1
  let id: Int
  let firstName: String
  let lastName: String
  let dob: String // ISO8601 format
  let phoneNumber: PhoneApiV2
  let address: AddressApiV2
  let email: String

  struct PhoneApiV2: Codable { // 1
    let countryPrefix: String
    let number: String
  }

  struct AddressApiV2: Codable { // 1
    let street: String
    let city: String
    let state: String
    let postalCode: String
  }
}
  1. You notice here that all objects are conforming to the Codable protocol because this is a requirement that will allow us to encode/decode objects.
    The good thing here to notice is that this Codable conformance is an implementation detail from the API side that will not pollute other user objects.

Now create the User object that will be used everywhere in the app

struct User {
  let id: Int
  let firstName: String
  let lastName: String
  let dob: String
  let phoneNumber: Phone
  let address: Address
  let email: String

  struct Phone {
    let countryPrefix: String
    let number: String
  }

  struct Address {
    let street: String
    let city: String
    let state: String
    let postalCode: String
  }
}
  • The important thing to note here is that no more Codable conformance and it's a good point, we don't want to leak implementation details from infrastructure (API, database, ...) in our model app.

  • The User object keeps the same property name but feel free you change it if you want.

  • The User object keeps the same object structure but you can adapt it as you want.

  • The User object keeps all properties of the UserApiV2 object, because it needs them all (but if some are not neccessary you can omit them and come back later to add them if needed).

  • So this User model is the model of your app, It belongs to you and is your responsibility to shape it as you want it to be.

Now create the UserViewData model that will be used in the SwiftUI view.

struct UserViewData {
  let name: String
  let birthDate: String
  let phoneNumber: String
  let address: String
  let email: String
}
  • This model doesn't contain any logic, it just displays already formatted data that are required for the design.
    This is for this reason that all properties are of type String

Load User interface

First I will create an interface UserLoading that will abstract the User load using protocol

protocol UserLoading {
  func fetchUser() async throws -> User
}
  • We will always use this interface to fetch the user, which allows us to use different implementations (API, database, ...)

Models creation

Create UserApiV2 model

Now we need to create the UserApiV2 model.
We will mock the backend response using a local JSON

struct MockUrlSessionUserLoading: UserLoading {
  func getUser() async throws -> User {
    let url = Bundle.main.url(forResource: "user", withExtension: "json")! // 1
    let data = try Data(contentsOf: url)
    let userApiV2 = try JSONDecoder().decode(UserApiV2.self, from: data) 
    return userApiV2.toUser() // 2
  }
}
  1. We need to create the JSON file user.json that be read locally.

  2. We must create the toUser() function that will convert the UserApiV2 object to the User object we want to return.

Start creating the user.json file
Create a new file
In the Other section choose Empty and call it user.json

In the user.json copy and paste this code:

{
    "id": 1,
    "firstName": "John",
    "lastName": "Doe",
    "dob": "2023-09-10T10:00:00Z",
    "phoneNumber": {
        "countryPrefix": "+1",
        "number": "1234567890"
    },
    "address": {
        "street": "123 Apple St.",
        "city": "Cupertino",
        "state": "CA",
        "postalCode": "95014"
    },
    "email": "john.doe@example.com"
}

Now create the toUser() function that will be an extension of the UserApiV2 type

extension UserApiV2 {
  func toUser() -> User {
    return User(
      id: self.id,
      firstName: self.firstName,
      lastName: self.lastName,
      dob: self.dob,
      phoneNumber: .init(
        countryPrefix: self.phoneNumber.countryPrefix,
        number: self.phoneNumber.number
      ),
      address: .init(
        street: self.address.street,
        city: self.address.city,
        state: self.address.state,
        postalCode: self.address.postalCode
      ),
      email: self.email
    )
  }
}
  • Here conversion is very straightforward, all the properties are identical (that is not always the case)

ViewModel creation

Let's create the view model that will be injected into the SwiftUI view

class UserViewModel: ObservableObject {
  @Published var userViewData = emptyUserViewData()

  private let userLoader: UserLoading

  init(userLoader: UserLoading) { // 1
    self.userLoader = userLoader
  }

  func getUser() {
    Task {
      do {
        let user = try await userLoader.getUser() // 2
        userViewData = user.toUserViewData() // 3
      } catch {
        // Handle any errors
      }
    }
  }
}

private func emptyUserViewData() -> UserViewData {
    UserViewData(name: "", birthDate: "", phoneNumber: "", address: "", email: "")
}
  1. I use dependency injection to use the UserLoading .

  2. I get the user from the userLoader via the getUser() function defined in the interface.

  3. I converted the User object to the UserViewData object to be able to update the UI using the toUserViewData() function.

Create the toUserViewData() function using extension on the User object

extension User {
  func toUserViewData() -> UserViewData {
    return UserViewData(
      name: "\(self.firstName) \(self.lastName)", // 1
      birthDate: DateFormatter.displayDateFormatter.string(from: ISO8601DateFormatter().date(from: self.dob) ?? Date()), // 2
      phoneNumber: "\(self.phoneNumber.countryPrefix) \(self.phoneNumber.number)", // 3
      address: "\(self.address.street)\n\(self.address.city), \(self.address.state) \(self.address.postalCode)", // 4
      email: self.email
    )
  }
}

private extension DateFormatter {
  static let displayDateFormatter: DateFormatter = {
    let formatter = DateFormatter()
    formatter.dateStyle = .medium
    return formatter
  }()
}
  1. For the name, I group the first and last name.

  2. The date is transformed into a readable format.

  3. The phone number is composed using the country prefix and number.

  4. The address is created with street, city, state and postal code and returned to the line when needed.

The UI will update every time this model changes


View Creation

Create the UserView

struct UserView: View {
  @ObservedObject var viewModel: UserViewModel

  var body: some View {
    Form {
      Text(viewModel.userViewData.name) // 1
      Text(viewModel.userViewData.birthDate) // 1
      Text(viewModel.userViewData.phoneNumber) // 1
      Text(viewModel.userViewData.address) // 1
      Text(viewModel.userViewData.email) // 1
    }
    .onAppear {
      viewModel.getUser()
    }
  }
}
  1. This is very simple, not need to handle the logic in the SwiftUI view, the heavy work has been already done. It just needs to render the view with the provided formatted data.

Let's create a preview to see if everything is working fine:

struct UserView_Previews: PreviewProvider {
  static var previews: some View {
    let userLoader = MockUrlSessionUserLoading()
    let viewModel = UserViewModel(userLoader: userLoader)
    UserView(viewModel: viewModel)
  }
}

The UI rendered as expected everything is working fine


Now we will expend the problem and imagine you have a new requirement:

"The cached user must be used in the remote get failed"

We now potentially use a model that has been used for the persistence of the User
We use Realm here to demonstrate the benefit of decoupling our model

Create UserRealm model

Start by creating all the types required to be able to persist the model UserRealm, PhoneRealm and AdressRealm

class UserRealm: Object {
  @objc dynamic var id: Int = 0
  @objc dynamic var firstName: String = ""
  @objc dynamic var lastName: String = ""
  @objc dynamic var dob: String = ""
  @objc dynamic var phone: PhoneRealm?
  @objc dynamic var address: AddressRealm?
  @objc dynamic var emailAddress: String = ""

  convenience init(id: Int, firstName: String, lastName: String, dob: String, phone: PhoneRealm, address: AddressRealm, emailAddress: String) {
    self.init()
    self.id = id
    self.firstName = firstName
    self.lastName = lastName
    self.dob = dob
    self.phone = phone
    self.address = address
    self.emailAddress = emailAddress
  }

  required override init() {
    super.init()
  }
}

class PhoneRealm: Object {
  @objc dynamic var countryPrefix: String = ""
  @objc dynamic var number: String = ""

  convenience init(countryPrefix: String, number: String) {
    self.init()
    self.countryPrefix = countryPrefix
    self.number = number
  }

  required override init() {
    super.init()
  }
}

class AddressRealm: Object {
  @objc dynamic var street: String = ""
  @objc dynamic var city: String = ""
  @objc dynamic var state: String = ""
  @objc dynamic var postalCode: String = ""

  convenience init(street: String, city: String, state: String, postalCode: String) {
    self.init()
    self.street = street
    self.city = city
    self.state = state
    self.postalCode = postalCode
  }

  required override init() {
    super.init()
  }
}

We also need to create the extension that will allow us to convert UserRealm to User

extension UserRealm {
  func toUser() -> User {
    return User(
      id: self.id,
      firstName: self.firstName,
      lastName: self.lastName,
      dob: self.dob,
      phoneNumber: User.Phone(
        countryPrefix: self.phone?.countryPrefix ?? "",
        number: self.phone?.number ?? ""
      ),
      address: User.Address(
        street: self.address?.street ?? "",
        city: self.address?.city ?? "",
        state: self.address?.state ?? "",
        postalCode: self.address?.postalCode ?? ""
      ),
      email: self.emailAddress
    )
  }
}

Create the MockRealmUserLoading will fake the query in the database

struct MockRealmUserLoading: UserLoading {
  func getUser() async throws -> User {
    let mockPhone = PhoneRealm(countryPrefix: "+1", number: "1234567890")
    let mockAddress = AddressRealm(street: "123 Apple St", city: "Tech Town", state: "Silicon Valley", postalCode: "95014")
    let mockRealmUser = UserRealm(id: 1, firstName: "John", lastName: "Doe", dob: "1990-01-01T00:00:00Z", phone: mockPhone, address: mockAddress, emailAddress: "john.doe@example.com")

    return mockRealmUser.toUser()
  }
}

Now the requirement is to start from remote and if failed return the user locally
We can create a new object RemoteUserLoadingWithLocalFallback that will compose those types:

class RemoteUserLoadingWithLocalFallback: UserLoading {
  let primary: UserLoading
  let secondary: UserLoading

  init(primary: UserLoading, secondary: UserLoading) {
    self.primary = primary
    self.secondary = secondary
  }

  func getUser() async throws -> User {
    do {
      return try await primary.getUser()
    } catch {
      return try await secondary.getUser()
    }
  }
}

The app still compiles.
The live preview still works when updating the composition using the RemoteUserLoadingWithLocalFallback

struct UserView_Previews: PreviewProvider {
  static var previews: some View {
    let userRemoteLoader = MockUrlSessionUserLoading()
    let userLocalLoader = MockRealmUserLoading()
    let userLoader = RemoteUserLoadingWithLocalFallback(
      primary: userRemoteLoader,
      secondary: userLocalLoader
    )
    let viewModel = UserViewModel(userLoader: userLoader)
    UserView(viewModel: viewModel)
  }
}

Of course, you can decide to replace frameworks:
URLSession -> Alamofire
Realm -> CoreData
You just need to create new types AlamofireUserLoading and CoreDataUserLoading that will conform to the UserLoading interface and injecting those new types will be an easy change because the entire app is not coupled with any framework, they are just details of the implementation


Conclusion

I hope that you enjoy the power of the domain-specific model.
Through domain-specific modeling (UserAPIV2, UserRealm), we have created a flexible structure that not only preserves the integrity of our SwiftUI app (User) but also makes it adaptable to different data sources (UserDataView).
Whether we're fetching data from an API or a local Realm database, the foundation remains unaffected.
This modular approach ensures that our app can scale with changing requirements without significant refactoring, ensuring a robust and resilient architecture.

That's it !! ๐ŸŽ‰

ย