A Pragmatic Guide to Clean Architecture on iOS

Bilescu Adrian
6 min readDec 10, 2023
Photo by Nick Wessaert on Unsplash

There seems to be a constant debate about the over-engineering part of Clean Architecture on iOS. For a couple of years now, MVVM has been widely adopted. I would argue that separating business logic from UI logic is already a step towards Clean Architecture.

For many small apps this might more than enough. But similarly, you could argue that having everything crammed into a View is more than enough for a small app.

VIPER and Misinterpretations of Clean Architecture

As the codebase grows, managing all the logic becomes a bit more difficult.

VIPER is one of the most famous proponents. At part, I suspect this might be also the case as to why Clean Architecture has a bad reputation for overcomplicating the code. I saw it very often implemented as a template architecture. For every screen, we need a View, Presenter, Interactor, and Router. There are also a lot of tools that let you generate all this boilerplate. Which is in my opinion the wrong approach to solving this issue.

At the same time, Caio & Mike from the Essential Developer have deconstructed the VIPER architecture here

Clean iOS Architecture pt.6: VIPER — Design Pattern or Architecture?

They demonstrated that the VIPER is not clean in the end.

Another pitfall I fell a long time ago was a so-called “Clean Swift”. It was also misusing Clean Architecture. This approach approach has been debunked also by Essential Developer 5 years ago

Clean iOS Architecture pt.7: VIP (Clean Swift) — Design Pattern or Architecture?

Simplifying Clean Architecture

Going back at the Clean Architecture book and looking into other proponents of good architecture I’ve managed to simplify the approach over the years. Now I’m able to adapt it to the scope of the project. No more templates, no more boilerplate code.

Clean Architecture is a framework that indicates how components should communicate with each other, putting the domain at the core, agnostic from any external dependencies. In my experience it’s just a way to arrange the same code but with a mental shift.

Now whenever I start a new app, I look from the domain to the UI. To do this effectively I’ve looked closer into Domain Driven Design, giving many more nuanced about the domain models.

The Core of Clean Architecture

Identify the models that make up the business logic like in a food ordering app

struct MenuItem {
let id: UUID
var name: String
var description: String
var price: Double
var category: String
var imageUrl: URL?
}
struct Restaurant {
typealias ID = UUID
let id: ID
var name: String
var address: String
var menuItems: [MenuItem]
}
struct Customer {
let id: UUID
var name: String
var email: String
var deliveryAddress: String
}
struct Order {
let id: UUID
let customerId: UUID
var items: [OrderItem]
var paymentDetails: PaymentDetails
var deliveryDetails: DeliveryDetails
var status: OrderStatus
}
struct OrderItem {
let menuItemId: UUID
var quantity: Int
var specialInstructions: String?
}
struct PaymentDetails {
var method: PaymentMethod
var cardNumber: String
var expiryDate: Date
var cvv: String
}
enum PaymentMethod {
case creditCard
case paypal
case applePay
}
struct DeliveryDetails {
var address: String
var estimatedDeliveryTime: Date
}
enum OrderStatus {
case pending
case confirmed
case inPreparation
case outForDelivery
case delivered
case cancelled
}

My approach is to make simple structures agnostic from external factors.

This is pretty static at this point, so to bring some action into the app, we need use cases. In a similar fashion to the domain models, the use cases will be as simple as possible and built conveniently for the app.

Use Cases

Here is an example of use case definitions as protocols. There are also other ways but this one I like how well it shows its intent.

protocol LoginUseCase {
func login(email: String, password: String) async throws
}
protocol FetchRestaurantsUseCase {
func fetch() async throws -> [Restaurant]
}
protocol RegisterCustomerUseCase {
func register(customer: Customer, password: String) async throws
}
protocol PlaceOrderUseCase {
func place(order: Order) async throws
}

At this stage we can already imagine some of the functionalities of your application. The exact way this will be implemented should remain a detail.

MVx Patterns and MVVM

Now we apply our favorite MVx design pattern to separate UI from the business logic.

Usually, I apply MVVM at this stage, where the view model receives use cases and manages their use. We will inject the use case implementation into the view model. This makes the view model a great candidate for testing

Very often I end up for every screen to define an associated view model. This doesn’t have to be the case all the time.

Here is an example of a LoginViewModel making use of a login use case

class LoginViewModel: ObservableObject {
@Published var email: String = ""
@Published var password: String = ""
@Published var errorMessage: String?

private var loginUseCase: LoginUseCase

init(loginUseCase: LoginUseCase) {
self.loginUseCase = loginUseCase
}

func loginSelected() async {
guard hasValidCredentials else { return }

do {
try await loginUseCase.login(email: email, password: password)
} catch {
errorMessage = error.localizedDescription
}
}
}

At this point, we add to our states some color. A view should define how each state should be drawn onto the screen and intercept the user interaction.

import SwiftUI

struct LoginView: View {
@ObservedObject var viewModel: LoginViewModel
var body: some View {
VStack {
TextField("Email", text: $viewModel.email)
.textFieldStyle(RoundedBorderTextFieldStyle())
.autocapitalization(.none)
.keyboardType(.emailAddress)
.padding()
SecureField("Password", text: $viewModel.password)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
.foregroundColor(.red)
.padding()
}
Button("Login") {
Task {
await viewModel.loginSelected()
}
}
.padding()
}
.padding()
}
}
struct LoginView_Previews: PreviewProvider {
static var previews: some View {
LoginView(viewModel: LoginViewModel(loginUseCase: DummyLoginUseCase()))
}
}

It becomes convenient for Preview to create dummy data and test your view with various types of data.

Additionally, it’s worth noting that I prefer to create the ViewModel outside the view, typically in a flow component. This aspect often depends greatly on individual preferences for managing navigation.

I frequently use a use case factory, established at the app’s launch and subsequently integrated into the flow. This approach provides a centralized decision point for how each use case is implemented.

Here is a diagram of my implementation of Clean Architecture, showcasing various examples of implementations of the use cases.

Dependency diagram of a Clean Architecture implementation

My initial implementations are typically built with some form of in-memory service. This strategy allows the creation of diverse complex logic through relatively simple code. In-memory services have been useful in demonstrating app functionalities to stakeholders, especially when external services were unavailable or undergoing maintenance or were still under development.

Another potential implementation strategy involves combining APIs with Core Data to facilitate an offline mode. The key here is to delay major decisions and keep the implementation as straightforward as possible.

I have observed that deferring these decisions until there is a clearer understanding of the product by the team can be critical.

This approach is particularly effective if you need to frequently alter the implementation of use cases and manage constant design changes.

Working in larger teams can also become more manageable, as Clean Architecture allows for more effective work division.

As demonstrated, we have expanded upon the classical MVVM framework by creating a dependency-free domain. As the codebase grows and the final product increases in complexity, this approach can evolve into more sophisticated structures

Conclusion

Clean Architecture doesn’t have to complicate the implementation. Beware of template-based development. We make custom-tailored apps for business needs. From this perspective, the architecture should serve the needs of the business and produce value to the end user.

Don’t trust random articles and their implementations, including this one. Make your own research and adapt it to your needs.

Checkout also the Code Sample on GitHub:

https://github.com/diti223/SuperDish/tree/main/

If you need guidance on your iOS development journey, you can book a FREE session with me 🚀

--

--