SwiftUI - Decoupling Models
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:
UserApiV2 - Represents our data source's structure from an API.
User - Acts as an intermediary and generic model, used in the entire app.
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
}
}
- 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 thisCodable
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 theUserApiV2
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 typeString
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
}
}
We need to create the JSON file user.json that be read locally.
We must create the
toUser()
function that will convert theUserApiV2
object to theUser
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: "")
}
I use dependency injection to use the
UserLoading
.I get the user from the
userLoader
via thegetUser()
function defined in the interface.I converted the
User
object to theUserViewData
object to be able to update the UI using thetoUserViewData()
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
}()
}
For the name, I group the first and last name.
The date is transformed into a readable format.
The phone number is composed using the country prefix and number.
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()
}
}
}
- 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 !! ๐
Subscribe to my newsletter
Read articles from Alexandre Alcuvilla directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by