A Beginner’s Guide to Clean Architecture in SwiftUI: Building Better Apps Step by Step

Wahyu Alfandi
9 min readAug 24, 2023

--

Photo by Lala Azizli on Unsplash

Do you feel that adding new features to your application is more difficult than building an application from scratch? If so, it is likely due to a messy code structure. In this article, we will explore how implementing clean architecture can help applications withstand the growing complexity of features. Additionally, we will demonstrate the application of clean architecture in a simple Pokedex project within this article.

What is Clean Architecture?

Clean Architecture

Clean Architecture is a software architecture pattern popularized by Robert C. Martin. Clean architecture emphasizes the separation of functions between different layers to create an application structure that is isolated, testable, and easy to maintain.

Clean Architecture is like building a sturdy and organized house for your computer programs. Imagine your program is a big puzzle made of different pieces. Clean Architecture helps you put those puzzle pieces in the right places so that your program works well and stays strong.

Benefits of adopting clean architecture:

  1. Independent of framework: It is not reliant on the specific framework’s implementation and positions the framework solely as a tool.
  2. Independent of UI: The user interface can be changed effortlessly without the need to alter the entire system.
  3. Independent of the database: It is not tied to a particular database framework and can be swapped easily.
  4. Independent of external factors: Existing business processes do not need to be aware of external conditions.
  5. Testable: Business process code can be tested without requiring the UI, database, or other external components.

Dependency Rules

The Dependency Rule says that source code dependencies must always flow inward. This means that elements within an inner circle cannot possess any knowledge about entities within an outer circle. The name declared in an outer circle must not be mentioned by the code in the inner circle. This includes functions, classes, variables or any other named software entity.

By enforcing that dependencies flow only inward, the architecture becomes highly modular. Each circle encapsulates a distinct area of functionality. This promotes ease of maintenance as changes within an inner circle have minimal impact on the outer circles and vice versa.

Understanding The Layers

Commonly, the implementation of Clean Architecture divides a project into three big layers:

  1. Domain Layer: Here, the core business logic and rules reside. It defines how key functions work, reflecting the app’s unique purpose. This layer remains independent of external factors like user interfaces or databases. It consists of two vital parts: Use Cases and Entities.
  2. Presentation Layer: Focuses on how the information is displayed and interacted with by users. It ensures data from the domain layer is user-friendly and easily understandable.
  3. Data Layer: The data layer manages the storage and retrieval of information used by the application. It communicates with databases, external APIs, and other sources to fetch or save data. Within this layer, you’ll encounter two crucial elements: DataSource and Repository.

We will dive deeper into all these layers in the example project later.

Clean Architecture In Action

In this project, we will use the pokeAPI as our data source. We will demonstrate one of the use cases, which is fetching a list of Pokémon. For more information about the API we’re using, you can refer to the following link.

First of all, let’s take a look at the folder/project structure we will create:

Let’s start with the DOMAIN layer.

The Domain describes WHAT the application or your project does. When you look at the structure of the application’s folders, it might sometimes be hard to recognize the app you are looking at. Think of it like looking at a map of a building — you should be able to easily understand what type of building it is and what each room or part of the building is for.

https://id.pinterest.com/pin/327777679099875768/

Just like that, the domain layer should be created in a similar way, so that the functions and purposes of the application are easier to understand.

  1. Model is a representation of real-world objects related to the problem. To put it simply, models are like the main characters in a story. In this situation, the models could be Pokémon, users, and more.
  2. Repository acts as an intermediary between the Domain Layer and external data sources such as databases, APIs, or file storage. It stores specific operations related to models. You can imagine a repository like a librarian; they know where the books are and how to retrieve and store the necessary ones. In this context, the term “PokemonRepository” would describe repository methods. The actual implementation of the repository will be kept within the Data Layer.
  3. Usecase contains list of functionality of our application. In this case are GetPokemons, GetPokemonDetail, GetUserDetail and so on.

The PRESENTATION layer focuses on how the information is displayed and interacted with by users. In this case, presentation layer would contain screens and its view model.

The DATA layer contains the implementation of the repository as well as data sources, both from local sources (such as CoreData, SQLite, etc.) and remote sources (APIs).

  1. Datasource refers to the place where the application gets its information, like APIs or local databases. it can also hold forms of data like the answers received from APIs or databases. Because we can’t control how data is shaped and stored in an outside data source, it’s crucial to perform mapping on these response outcomes before sending them to the domain layer.

And finally, the CORE layer can contain components that can be accessed from various layers, such as utilities, dependency injection, extensions, configuration, etc.

After all this explanation, lets start to actual code implementation. First we need to always starts with entities in domain layer.

struct PokemonEntryModel{
var id: UUID = .init()
let name: String
let url: String

var imageUrl: String {
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/\(url.split(separator: "/").last ?? "1").png"

}
}

We’ll use this model to display list of pokemon in our pokemon list view.

Next let’s create our pokemonEntryResponse.

struct PokemonResponse: Decodable {
let count: Int?
let results: [PokemonEntryResponse]?
}

struct PokemonEntryResponse: Decodable {
let name: String?
let url: String?
}

In the code above, we didn’t make just one response object; we made two. We did this because the way the pokeAPI sent back the information needed two separate objects to be able to decode the pokemon list.

{
"count": 1281,
"next": "https://pokeapi.co/api/v2/pokemon?offset=20&limit=20",
"previous": null,
"results": [
{
"name": "bulbasaur",
"url": "https://pokeapi.co/api/v2/pokemon/1/"
},
{
"name": "ivysaur",
"url": "https://pokeapi.co/api/v2/pokemon/2/"
}
]
}

Next, lets make an interface (protocol) for the RemoteDataSource.

protocol RemoteDataSoureProtocol{
func getPokemons(offset: Int, limit: Int) async throws -> PokemonResponse
}

And after that, we need to implement the protocol above to a class.

struct RemoteDataSource {
private init() {}

static let shared: RemoteDataSource = RemoteDataSource()
}

extension RemoteDataSource: RemoteDataSoureProtocol{

func getPokemons(offset: Int, limit: Int) async throws -> PokemonResponse {
guard let url = URL(string: Endpoints.Gets.pokemons(offset: offset, limit: limit).url) else {throw URLError.invalidURL}

let (data, response) = try await URLSession.shared.data(from: url)

guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
throw URLError.invalidResponse
}

do {
return try JSONDecoder().decode(PokemonResponse.self, from: data)
} catch {
throw URLError.parsingError
}
}
}

Note: The data obtained from the datasource is still in the form of pokemonResponse. We will map this data into domain models in the repository.

Next, we will create the repository implementation in the data layer, but before that, we need to create the repository interface (protocol) in the domain layer.

protocol PokemonRepositoryProtocol{
func getPokemons(offset: Int, limit: Int) async throws -> [PokemonEntryModel]
}

Now, let’s implement the created Protocol.

struct PokemonRepositoryImpl {
typealias PokemonInstance = (RemoteDataSource) -> PokemonRepositoryImpl

fileprivate let remote: RemoteDataSource

private init(remote: RemoteDataSource) {
self.remote = remote
}

static let sharedInstance: PokemonInstance = { remoteRepo in
return PokemonRepositoryImpl(remote: remoteRepo)
}
}

extension PokemonRepositoryImpl: PokemonRepositoryProtocol {

func getPokemons(offset: Int, limit: Int) async throws -> [PokemonEntryModel] {
do {
let data = try await remote.getPokemons(offset: offset, limit: limit)

return PokemonMapper.mapPokemonsResponseToDomain(input: data.results ?? [])
} catch {
throw error
}
}
}

In the above code, we use the PokemonMapper enum for the mapping process. Here’s the code for the PokemonMapper.

enum PokemonMapper {
static func mapPokemonsResponseToDomain(input response: [PokemonEntryResponse]) -> [PokemonEntryModel] {
return response.map { result in
return PokemonEntryModel(
name: result.name ?? "Unknown",
url: result.url ?? "Unknown"
)
}
}
}

Now that we have our PokemonRepository so we can code GetPokemonsUsecase next.

protocol GetPokemonsUseCase {
func execute(offset: Int, limit: Int) async throws -> Result<[PokemonEntryModel], Error>
}

struct GetPokemonsUseImpl: GetPokemonsUseCase{
let repo: PokemonRepositoryProtocol

func execute(offset: Int, limit: Int) async throws -> Result<[PokemonEntryModel], Error> {
do {
let pokemons = try await repo.getPokemons(offset: offset, limit: limit)
return .success(pokemons)
} catch {
return .failure(error)
}
}
}

And after that we can write our presentation’s view and viewmodel.

@MainActor class HomeViewModel: ObservableObject {
let getPokemonsUseCase: GetPokemonsUseCase

@Published var pokemons: [PokemonEntryModel] = []
@Published var isLoading: Bool = false
@Published var errorMessage: String?

init(getPokemonsUseCase: GetPokemonsUseCase){
self.getPokemonsUseCase = getPokemonsUseCase
}

func getPokemons(offset: Int, limit: Int) async throws {
isLoading = true
let result = try await getPokemonsUseCase.execute(offset: offset, limit: limit)
switch result {
case .success(let pokemons):
self.pokemons = pokemons
self.isLoading = false

case .failure(let failure):
self.isLoading = false
self.errorMessage = failure.localizedDescription
}
}
}

struct HomeView: View {
@StateObject var vm: HomeViewModel = .init(
getPokemonsUseCase: Injection.shared.provideGetPokemonsUseCase()
)

var body: some View {
VStack(alignment: .leading) {
logo
if vm.isLoading {
Spacer()
HStack{
Spacer()
ProgressView()
Spacer()
}
Spacer()
} else {
pokemonList
}

}
.ignoresSafeArea()
.background(
VStack{
Image("bg")
.padding(.top, -100)
Spacer()
}
)
.task {
do{
try await vm.getPokemons(offset: 0, limit: 10)
}catch{
print(error)
}
}
}
}

extension HomeView {
var logo : some View {
HStack{
Spacer()
Image("logo")
.resizable()
.scaledToFill()
.frame(width: 130, height: 48)
Spacer()
}
.padding(.top, 64)
.padding(.horizontal, 24)
}

var title : some View {
Text("Pokédex")
.font(.system(size: 24, weight: .semibold, design: .rounded))
.foregroundColor(.black)
.padding(.horizontal, 24)
}

var pokemonList : some View {
ScrollView {
HStack {
title
Spacer()
}

LazyVGrid(
columns: [GridItem(.adaptive(minimum: 150)), GridItem(.adaptive(minimum: 150))], spacing: 16
) {
ForEach(vm.pokemons, id:\.id) { pokemon in
PokemonCard(pokemonEntry: pokemon)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.scrollIndicators(.hidden)
}
}

struct PokemonCard: View {
let pokemonEntry: PokemonEntryModel

var body: some View {
VStack{
AsyncImage(url: URL(string: pokemonEntry.imageUrl)) { image in
image.resizable()
.scaledToFit()
.frame(width: 120, height: 120)
} placeholder: {
Rectangle().fill(.ultraThinMaterial)
.frame(width: 120, height: 120)
}

Text(pokemonEntry.name.capitalized)
.font(.system(size: 18, weight: .semibold, design: .rounded))
.foregroundColor(.black)
.padding(.top, 4)
}
.padding(16)
.background(.white)
.cornerRadius(8)
.shadow(color: Color("shadowColor"), radius: 24, x: 0, y: 16)

}
}

Conclusion

In conclusion, Clean Architecture offers a structured and powerful approach to building software that stands the test of time. By segregating our application into distinct layers — Domain, Presentation, and Data — we ensure that each part has a specific role and remains isolated from the others. This not only enhances maintainability but also fosters adaptability to changing business needs and technological advancements.

From Author

Thank you so much for reading all the way through! Your dedication to exploring this content is truly appreciated. If you’ve got any thoughts, feedback, or insights to share, don’t hesitate to drop a comment in the section below. Whether it’s a suggestion, a question, or just a friendly hello, I’m all ears. Your input helps make this a more engaging and informative space for everyone. Looking forward to hearing from you💫✨

--

--

Wahyu Alfandi

Learner at Apple Developer Academy @ILB. I wrote article as my learning note.