iOS App with Clean Architecture – Example

In our previous post, we defined that we would structure our project by combining a vertical split by features and a horizontal split of each feature by layers, following the clean architecture approach. In this example we will create an iOS App using Clean Architecture.

To illustrate this, we are creating an example application that will load a list of players from an API and display it on the UI. For now, we have only one feature, “ListPlayers”. As the project grows, we could separate each feature into different modules or Swift packages. However, to keep things simple for this example, we will structure our project using folders. We need a parent group for the “ListPlayers” feature, and within it, we will separate the code into the Data, Presentation, and Domain layers.

To examine each of the components, we will follow how the data flows. First, the view appears, and then it sends a message to the presenter. The presenter will then execute a use case, which will request data from the repository. The repository will load the data from the data sources, and the information will flow back until the UI updates.

The flow is as follows: View -> Presenter -> Use Case -> Repository -> Data Source, and then the reversed flow until the UI updates.

Presentation

Let’s begin with the presentation layer. Since this series of posts focuses on Clean Architecture, I won’t go into much detail about SwiftUI, presenters, view models, and how they communicate with each other. Hopefully, in a later post, we can discuss these and other patterns in more depth. However, for our example project, I will use a typical approach that includes a SwiftUI view, a presenter, and the ObservableObject protocol, which enables the view to update automatically when a model changes.

The presentation layer has the following structure:

ListPlayersViewModel is a simple class that implements the ObservableObject protocol, which enables the view to observe changes in the model and perform updates. There is no logic inside the class, just simple structs or primitive types that we will use for the view. In our case, it will contain a value to indicate when the view is loading, and a simple struct that will contain the list of players to display when it is loaded.

ListPlayersViewModel.swift
import Foundation

final class ListPlayersViewModel: ObservableObject {
    @Published var descriptor: ListPlayerDescriptor
    @Published var isLoading: Bool
    
    init(
        descriptor: ListPlayerDescriptor,
        isLoading: Bool
    ) {
        self.descriptor = descriptor
        self.isLoading = isLoading
    }
}

struct ListPlayerDescriptor {
    let players: [PlayerDescriptor]
}

struct PlayerDescriptor: Identifiable {
    let id: UUID
    let displayName: String
    let imageURL: URL?
}

Now let’s move on to the view. I will show only the most important part of the code here, but you can download the full project from here. The view has a reference to the presenter interface, which exposes an observable view model that the view will listen to for changes and updates. Inside the view model, we have the list of players that are loaded, as well as a boolean indicating when the load is in progress. To simplify the example, we are not displaying any progress indicator. The presenter interface also exposes a method to indicate when the view appears.

ListPlayersView.swift
struct ListPlayersView: View {
    let presenter: ListPlayersPresenter
    @ObservedObject var viewModel: ListPlayersViewModel
    
    init(presenter: ListPlayersPresenter) {
        self.presenter = presenter
        self.viewModel = presenter.viewModel
    }
    
    var body: some View {
        VStack {
            ForEach(viewModel.descriptor.players) { player in
                buildRow(player: player)
            }
            Spacer()
        }
        .task {
            await presenter.onAppear()
        }
        .padding(.all, 16)
        .navigationTitle("List Players")
    }

Now let’s take a look at the presenter. First, we have the interface that the view depends on, which exposes a view model and a method to indicate when the view appears.

The implementation, ListPlayersPresenterImpl, has a dependency on the interface from the FetchPlayersUseCase, which is why the presentation layer depends on the domain layer since this interface is part of the domain layer. This use case will return the domain models that contain the list of players.

When the view appears, the onAppear method is called, and the presenter calls the use case. The use case returns the domain model, which the presenter then maps into a descriptor that is used for the view. The descriptor is a simple struct that contains only the information from the domain model that is needed for the view, and in the format that will be displayed on the UI. For example, our domain model returned by the use case has the given name and surname in two different properties. However, in our UI, we will display this information on a single line, combining both. Therefore, our descriptor contains the display name already in the format required for the UI. This is specific to this screen, and perhaps in another screen, we will only display the last name.

As another example, our model can contain a Date object, and we may want to display the date in different formats depending on the case. That’s why we map the domain model into a descriptor with only the information needed for the view and in the appropriate format.

ListPlayersPresenter.swift
protocol ListPlayersPresenter {
    var viewModel: ListPlayersViewModel { get }
    func onAppear() async
}

struct ListPlayersPresenterImpl: ListPlayersPresenter {
    var viewModel: ListPlayersViewModel
    let useCase: FetchPlayersUseCase
    
    init(
        viewModel: ListPlayersViewModel,
        useCase: FetchPlayersUseCase
    ) {
        self.viewModel = viewModel
        self.useCase = useCase
    }
    
    @MainActor
    func onAppear() async {
        viewModel.isLoading = true
        do {
            let model = try await useCase.execute()
            let playersDescriptor = model.map {
                PlayerDescriptor(
                    id: $0.id,
                    displayName: "\($0.givenName) \($0.surname)",
                    imageURL: $0.imageURL
                )
            }
            viewModel.descriptor = ListPlayerDescriptor(players: playersDescriptor)
        } catch {
            // Handle error case
        }
        viewModel.isLoading = false
    }
}

In the end, we update our view model with the descriptor containing the list of players, since the view is observing changes in this model, will update automatically when this finish.

Domain

Now let’s take a closer look at the core of our application, which contains the business logic. This is how we have structured our domain layer:

In our simple example, our domain model is just a simple struct that describes a player entity in the most suitable format for the domain layer. We have separate properties for the player’s given name and surname, and an optional imageURL since it is not guaranteed to be available for all players. This model has no influence on the format in which we receive the data from the network response, or on how we want to display this information on the UI.

PlayerEntity.swift
struct PlayerEntity {
    let id: UUID
    let givenName: String
    let surname: String
    let imageURL: URL?
}

As part of the interfaces, we declare the interfaces that this domain layer needs to work with. We have a use case that will need to fetch data from a repository, so we declare the interface PlayersRepository that the use case will use. This interface is part of the domain model to avoid having a dependency on another layer, and it is responsibility of the data layer to provide a repository that implements this interface.

PlayersRepository.swift
protocol PlayersRepository {
    func fetchPlayers() async throws -> [PlayerEntity]
}

Last but not least, we have the FetchPlayersUseCase use case, which will be the command that the presentation layer uses to get the domain models. We declare an interface that the presenter will depend on, and then the implementation will use the repository interface to fetch the players.

FetchPlayersUseCase.swift
protocol FetchPlayersUseCase {
    func execute() async throws -> [PlayerEntity]
}

struct FetchPlayersUseCaseImpl: FetchPlayersUseCase {
    let repository: PlayersRepository
    
    func execute() async throws -> [PlayerEntity] {
        return try await repository.fetchPlayers()
    }
}

Data

The Data layer is responsible for storing and retrieving data. In our simple case, we retrieve data from an API, so we have a network data source. Depending on the case, we may have other data sources for databases, in-memory storage, or any other kind of storage.

Here is the structure of our layer, which includes the network data source and the repository implementation.

The first component is the repository implementation, which is responsible for implementing the interface that is used by the use case. This interface is located in the domain layer, which is why the data layer has a dependency on the domain layer. Additionally, the implementation can have one or multiple data sources, depending on the case. In our example, we have just a network data source.

The network data source returns its own model, which is a DTO (data transfer object). Therefore, it is the job of the repository to map this DTO to the domain model that is expected by the use case.

PlayersRepositoryImpl.swift
struct PlayersRepositoryImpl: PlayersRepository {
    let networkDataSource: NetworkPlayersDataSource
    
    func fetchPlayers() async throws -> [PlayerEntity] {
        return try await networkDataSource
            .fetchPlayers()
            .map { player in
                PlayerEntity(
                    id: player.id,
                    givenName: player.playerName.firstName,
                    surname: player.playerName.lastName,
                    imageURL: player.image.flatMap(URL.init(string:))
                )
            }
    }
}

The last component is the network data source. Our network data source includes an API client or network provider, and is responsible for mapping the response from the network. We have a DTO called PlayerDTO that contains the decoding information, and is in the most appropriate format for decoding the models. We separate this DTO model from the domain model to avoid changes in the API response affecting our domain model, and to prevent the response format and decoding logic from influencing how we structure our entities in the domain.

By separating the DTO model from the domain model, we ensure that changes to the network response format will not require changes to our domain model. This allows us to keep our domain model clean and independent of the implementation details of the network data source.

NetworkPlayersDataSource.swift
protocol NetworkPlayersDataSource {
    func fetchPlayers() async throws -> [PlayerDTO]
}

struct NetworkPlayersDataSourceImpl: NetworkPlayersDataSource {
    var fetch: () async throws -> Data
    
    func fetchPlayers() async throws -> [PlayerDTO] {
        let response = try await fetch()
        return try JSONDecoder().decode([PlayerDTO].self, from: response)
    }
}

struct PlayerDTO: Decodable {
    struct PlayerName: Decodable {
        let firstName: String
        let lastName: String
    }
    
    let id: UUID
    let playerName: PlayerName
    let image: String?
}

Conclusion

Up until this point, our simple example has followed the Clean Architecture approach, which appears to be easy to understand and well-organized. However, when working on a real project, things can become more complex. We may have multiple data sources, complex conditions, and a need to reuse domain models in multiple features. In future articles, we will address these challenges and explore how to handle them within the context of Clean Architecture. You can download the full project from here.

Although it may seem like there are many layers and a lot of code for a simple case like this, the Clean Architecture approach becomes more valuable as your application grows. In the next series of articles, we will add more complexity, such as additional data sources and business logic, to see how these cases should be handled. Please feel free to share any feedback, and we will see you next time!