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.swiftimport 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.swiftstruct 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.swiftprotocol 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.swiftstruct 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.swiftprotocol 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.swiftprotocol 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.swiftstruct 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.swiftprotocol 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!