Clean Architecture: iOS App
Photo by Michael Burrows from Pexels

Clean Architecture: iOS App

By employing clean architecture, you can design applications with very low coupling and independent of technical implementation details. That way, the application becomes easy to maintain and flexible to change.

Clean architecture allows us to create architectural boundaries between dependencies which allows components to be intrinsically testable.

What's to follow is our attempt to build an iOS application using clean architecture with TDD. The app will be used to manage contacts.

We are going to structure our application in the following way:

Our application is partitioned into Presentation, Domain and Data Layers.

The Presentation layer is responsible for all consumer facing components like views and view models.

The Domain Layer holds all business Logic (use cases) and System Logic (repositories).

And the Data Layer holds all infrastructure components, like data sources and services.

At specific points in our applications we define dependency rules with interfaces. We unit-test every component by mocking its dependencies.

Note: We don't unit-test our views as they should contain minimal logic to aid in rendering data graphically. It is therefore best to test them with the eye.

System Structure

The structure shows intent through filenames and folder structure

-- Contacts
    │── Presentation
    │   └── Contact
    │       ├── Create
    │       │   ├── ContactCreateViewModel.swift
    │       │   └── ContactCreateView.swift
    │       ├── Edit
    │       │   ├── ContactEditViewModel.swift
    │       │   └── ContactEditView.swift
    │       └── List
    │           ├── ContactListViewModel.swift
    │           └── ContactListView.swift
    ├── Domain
    │   ├── Protocols
    │   │   ├── UseCases
    │   │   │   └── Contact
    │   │   │       ├── CreateContactUseCaseProtocol.swift
    │   │   │       ├── UpdateContactUseCaseProtocol.swift
    │   │   │       ├── DeleteContactUseCaseProtocol.swift
    │   │   │       ├── GetContactUseCaseProtocol.swift
    │   │   │       └── GetAllContactsUseCaseProtocol.swift
    │   │   └── Repositories
    │   │       └── ContactRepositoryProtocol.swift
    │   ├── Models
    │   │   └── Contact.swift
    │   ├── UseCases
    │   │   └── Contact
    │   │       ├── CreateContact.swift
    │   │       ├── UpdateContact.swift
    │   │       ├── DeleteContact.swift
    │   │       ├── GetAllContacts.swift
    │   │       └── GetOneContact.swift
    │   └── Repositories
    │       └── ContactRepositoryImpl.swift
    └── Data
        ├── Protocols
        │   ├── Wrappers
        │   │   └── CoreDataWrapperProtocol.swift 
        │   └── ContactDataSourceProtocol.swift
        └── DataSources
            └── CoreData
                ├── Entities
                │   └── Contact.xcdatamodeld
                ├── Wrappers
                │   └── CoreDataWrapper.swift
                └── CoreDataContactDataSource.swift
-- ContactTests
    │── Mocks
    │   ├── Domain
    │   │   ├── Repositories
    │   │   │    └── MockContactRepository.swift
    │   │   └── UseCases
    │   │       └── Contact
    │   │           ├── MockCreateContact.swift
    │   │           ├── MockUpdateContact.swift
    │   │           ├── MockDeleteContact.swift
    │   │           ├── MockGetAllContacts.swift
    │   │           └── MockGetOneContact.swift  
    │   └── Data
    │       └── DataSources
    │           ├── MockContactDataSource.swift
    │           └── CoreData
    │               └── Wrappers
    │                   └── MockCoreDataWrapper.swift
    │── Presentation
    │   └── Contact
    │       ├── Create
    │       │   └── ViewModelContactCreateTests.swift
    │       ├── Edit
    │       │   └── ViewModelContactEditTests.swift
    │       └── List
    │           └── ViewModelContactListTests.swift
    ├── Domain
    │   ├── UseCases
    │   │   └── Contact
    │   │       ├── UseCaseContactCreateTests.swift
    │   │       ├── UseCaseContactUpdateTests.swift
    │   │       ├── UseCaseContactDeleteTests.swift
    │   │       ├── UseCaseContactsGetAllTests.swift
    │   │       └── UseCaseContactGetOneTest.swift
    │   └── Repositories
    │       └── ContactRepositoryTests.swift
    └── Data
        └── DataSources
            └── CoreDataContactDataSourceTests.swift

We create our application using an App target (Contacts) and Test target(ContactTests). The Test target mirrors the structure of the application target.

The first thing we usually do is ask ourselves what will the application do. This we define using models and interfaces/protocols.

Contact Model

Because the app involves contacts, we define it in the contact.swift file. In this model file, we define a response and a request model for contact. For simple entities you might not need to do this, however, we think it helps streamline the shape of the data to the data source (request model) and the data from the data source (response model). In a function like "create" we'll use the request model and in functions like "get" we'll only need the response model. Think of these models as data pipes up and down through the layers.

1//Contacts/Domain/Models/Contact.swift 2import Foundation 3struct ContactResponseModel: Identifiable, Equatable, Hashable { 4 let id: UUID 5 var name: String 6 7 init(){ 8 id = UUID() 9 name = "" 10 } 11 12 init(id: UUID, name: String){ 13 self.id = id 14 self.name = name 15 } 16 17} 18 19struct ContactRequestModel: Equatable { 20 var name: String 21 22 init(){ 23 name = "" 24 } 25 26 init(name: String){ 27 self.name = name 28 } 29 30}

Protocols

We use protocols or interfaces to illustrate intent and enforce behaviour of a class. The domain layer for example has a "Protocols" folder which holds all interfaces for that layer. Let's look at the domain protocols

Use Case Protocols

These use case protocols specify our use case rules

1//Contacts/Domain/Protocols/UseCases/Contact/CreateContactUseCaseProtocol.swift 2import Foundation 3protocol CreateContactUseCaseProtocol { 4 func execute(_ contact: ContactRequestModel) async -> Result<Bool, ContactError> 5}
1//Contacts/Domain/Protocols/UseCases/Contact/DeleteContactUseCaseProtocol.swift 2import Foundation 3protocol DeleteContactUseCaseProtocol { 4 func execute(_ id: UUID) async -> Result<Bool, ContactError> 5}
1//Contacts/Domain/Protocols/UseCases/Contact/GetAllContactsUseCaseProtocol.swift 2import Foundation 3protocol GetAllContactsUseCaseProtocol{ 4 func execute() async -> Result<[ContactResponseModel], ContactError> 5}
1//Contacts/Domain/Protocols/UseCases/Contact/GetContactUseCaseProtocol.swift 2import Foundation 3protocol GetContactUseCaseProtocol { 4 func execute(_ id:UUID) async -> Result<ContactResponseModel?, ContactError> 5}
1//Contacts/Domain/Protocols/UseCases/Contact/UpdateContactUseCaseProtocol.swift 2import Foundation 3protocol UpdateContactUseCaseProtocol { 4 func execute(id: UUID, data: ContactRequestModel) async -> Result<Bool, ContactError> 5}

Contact Repository Protocol

The repository is typically called by one or more use cases and is predominantly used to manage data sources. These are the methods that are available for the use cases.

1//Contacts/Domain/Protocols/Repositories/ContactRepositoryProtocol.swift 2import Foundation 3protocol ContactRepositoryProtocol{ 4 func getContacts() async -> Result<[ContactResponseModel], ContactError> 5 func getContact(_ id: UUID) async -> Result<ContactResponseModel?, ContactError> 6 func deleteContact(_ id: UUID) async -> Result<Bool, ContactError> 7 func updateContact(id: UUID, data: ContactRequestModel) async -> Result<Bool, ContactError> 8 func createContact(_ contactRequestModel: ContactRequestModel) async -> Result<Bool, ContactError> 9}

Contact Data Source Protocol

Data sources the implement this protocol would need to do the following operations

1//Contacts/Data/Protocols/DataSources/ContactDataSourceProtocol.swift 2import Foundation 3protocol ContactDataSourceProtocol{ 4 func getAll() async -> Result<[ContactResponseModel], ContactError> 5 func getOne(_ id: UUID) async -> Result<ContactResponseModel?, ContactError> 6 func create(_ contactRequestModel: ContactRequestModel) async -> Result<Bool, ContactError> 7 func update(id: UUID, data: ContactRequestModel) async -> Result<Bool, ContactError> 8 func delete(_ id: UUID) async -> Result<Bool, ContactError> 9}

View Model Tests

We'll use XCode to create a Unit Test Bundle Target for our Tests. Let's call it "ContactTests". Before creating any productions code let's first write the failing test for our contact list view model.

1//ContactTests/Presentation/Contact/List/ViewModelContactListTests.swift 2@testable import Contacts 3import XCTest 4 5class ViewModelContactListTests: XCTestCase { 6 var vm: ContactListViewModel! 7 var getContacts : MockGetAllContacts! 8 var deleteContact: MockDeleteContact! 9 10 11 override func setUp() { 12 getContacts = MockGetAllContacts() 13 deleteContact = MockDeleteContact() 14 vm = .init( 15 getAllContacts: getContacts, 16 deleteContact: deleteContact 17 ) 18 19 } 20 21 func test_deleteContact_should_return_success() async { 22 deleteContact.executeResult = .success(true) 23 let id = UUID() 24 await vm.deleteContact(id) 25 XCTAssertEqual(deleteContact.executeGotCalledWith, (id)) 26 } 27 28 func test_deleteContact_set_error_when_deleteContact_fails() async{ 29 deleteContact.executeResult = (.failure(ContactError.Get)) 30 await vm.deleteContact(UUID()) 31 XCTAssertEqual(vm.errorMessage, "Error Deleting Contact") 32 } 33 34 func test_should_set_contacts_with_data() async{ 35 let expectedResult: [ContactResponseModel] = [ 36 ContactResponseModel(id: UUID(), name: "Paul"), 37 ContactResponseModel(id: UUID(), name: "John") 38 ] 39 getContacts.executeResult = .success(expectedResult) 40 await vm.getContacts() 41 XCTAssertTrue(getContacts.executeGotCalled) 42 XCTAssertEqual(vm.contacts, expectedResult) 43 } 44 45 46 func test_should_set_error_when_getContacts_fails() async{ 47 getContacts.executeResult = (.failure(ContactError.Get)) 48 await vm.getContacts() 49 XCTAssertEqual(vm.contacts.count, 0) 50 XCTAssertEqual(vm.errorMessage, "Error Fetching Contacts") 51 } 52}

Now, of course all tests within this test suite will fail, in fact it won't even build. The view model and the mocks have not been created.

In the setup function of the test suite, we create mock use cases to inject into the view model we are testing

Let's first create our mock use cases. Before we start, just a quick note on mocking frameworks in swift:

Note: Swift does not allow read-write reflection. Read-write reflection basically allows for modifying programs at run time. Most mocking frameworks rely on this language feature. The mocking frameworks that are written for Swift, therefore, cannot use read-write reflection and has to use code generation at compile time to generate mocks. I'm not a fan of this and will therefore create my own simplistic mocks

Let's create the 2 use case mocks that the view model needs

1//ContactTests/Mocks/Domain/UseCases/Contact/MockGetAllContacts.swift 2@testable import Contacts 3 4import Foundation 5 6final class MockGetAllContacts: GetAllContactsUseCaseProtocol{ 7 var executeResult: Result<[ContactResponseModel], ContactError> = .success([]) 8 var executeGotCalled = false; 9 10 func execute() async -> Result<[ContactResponseModel], ContactError> { 11 executeGotCalled = true 12 return executeResult 13 } 14} 15
1//ContactTests/Mocks/Domain/UseCases/Contact/MockDeleteContact.swift 2@testable import Contacts 3 4import Foundation 5 6final class MockDeleteContact: DeleteContactUseCaseProtocol{ 7 var executeResult: Result<Bool, ContactError> = .success(true) 8 var executeGotCalledWith: (UUID) = (UUID()) 9 10 func execute(_ id: UUID) async -> Result<Bool, ContactError> { 11 executeGotCalledWith = (id) 12 return executeResult 13 } 14} 15

Finally, Let's also create our view model code so that our tests can pass

1//Contacts/Presentation/Contact/List/ContactListViewModel.swift 2import Foundation 3 4class ContactListViewModel: ObservableObject{ 5 private let getAllContacts: GetAllContactsUseCaseProtocol 6 private let deleteContact: DeleteContactUseCaseProtocol 7 8 init( 9 getAllContacts: GetAllContactsUseCaseProtocol, 10 deleteContact: DeleteContactUseCaseProtocol 11 ){ 12 self.getAllContacts = getAllContacts 13 self.deleteContact = deleteContact 14 } 15 16 @Published var errorMessage = "" 17 @Published var contacts : [ContactResponseModel] = [] 18 19 20 func deleteContact(_ id: UUID) async{ 21 let result = await self.deleteContact.execute(id) 22 switch result{ 23 case .success(_): 24 self.errorMessage = "" 25 case .failure(_): 26 self.errorMessage = "Error Deleting Contact" 27 } 28 } 29 30 func getContacts() async{ 31 32 let result = await self.getAllContacts.execute() 33 switch result{ 34 case .success(let contacts): 35 self.contacts = contacts 36 case .failure(_): 37 self.errorMessage = "Error Fetching Contacts" 38 39 } 40 } 41}

View Model

This view model has 4 public facing members:

  1. The list of contacts,
  2. The error message,
  3. The "getContacts" function
  4. The "deleteContact" function

Moving down vertical slice of functionality, next, we can write tests for the use cases

1//ContactTests/Domain/UseCases/Contact/UseCaseContactGetAllTests.swift 2@testable import Contacts 3import XCTest 4 5class UseCaseContactGetAllTests: XCTestCase { 6 var useCase: GetAllContacts! 7 var mockContactRepository : MockContactRepository! 8 9 override func setUp() { 10 mockContactRepository = MockContactRepository() 11 useCase = .init(contactRepo: mockContactRepository) 12 13 } 14 15 func test_repo_getContacts_should_be_called() async{ 16 let expectedResult = [ContactResponseModel(id: UUID(), name: "Paul")] 17 mockContactRepository.getContactsResult = .success(expectedResult) 18 let response = await useCase.execute() 19 XCTAssertEqual(response, .success(expectedResult)) 20 XCTAssertTrue(mockContactRepository.getContactsGotCalled) 21 } 22 23 24}
1//ContactTests/Domain/UseCases/Contact/UseCaseContactDeleteTests.swift 2@testable import Contacts 3import XCTest 4 5class UseCaseContactDeleteTests: XCTestCase { 6 var useCase: DeleteContact! 7 var contactRepository : MockContactRepository! 8 9 override func setUp() { 10 contactRepository = MockContactRepository() 11 useCase = .init(contactRepo: contactRepository) 12 13 } 14 15 func test_repo_deleteContact_should_be_called() async{ 16 contactRepository.deleteContactResult = .success(true) 17 let id = UUID() 18 let response = await useCase.execute(id) 19 XCTAssertEqual(contactRepository.deleteContactGotCalledWith, (id)) 20 XCTAssertEqual(response, .success(true)) 21 } 22 23 24}

Here the use case depends on the contact repository. Let's create the repo mock

1//ContactTests/Mocks/Domain/Repositories/MockContactRepository.swift 2@testable import Contacts 3 4import Foundation 5 6final class MockContactRepository: ContactRepositoryProtocol{ 7 8 var getContactsResult: Result<[ContactResponseModel], ContactError> = .success([]) 9 var getContactsGotCalled = false 10 func getContacts() async -> Result<[ContactResponseModel], ContactError> { 11 getContactsGotCalled = true 12 return getContactsResult; 13 } 14 15 var getContactResult: Result<ContactResponseModel?, ContactError> = .success(ContactResponseModel(id: UUID(), name: "Some Name")) 16 var getContactGotCalledWith = (UUID()) 17 func getContact(_ id: UUID) async -> Result<ContactResponseModel?, ContactError> { 18 getContactGotCalledWith = (id) 19 return getContactResult; 20 } 21 22 var deleteContactResult: Result<Bool, ContactError> = .success(false) 23 var deleteContactGotCalledWith = (UUID()) 24 func deleteContact(_ id: UUID) async -> Result<Bool, ContactError> { 25 deleteContactGotCalledWith = (id) 26 return deleteContactResult; 27 } 28 29 var updateContactResult: Result<Bool, ContactError> = .success(false) 30 var updateContactGotCalledWith = (UUID(), ContactRequestModel(name: "")) 31 func updateContact(id: UUID, data: ContactRequestModel) async -> Result<Bool, ContactError> { 32 updateContactGotCalledWith = (id, data) 33 return updateContactResult; 34 } 35 36 var createContactResult: Result<Bool, ContactError> = .success(false) 37 var createContactGotCalledWith = (ContactRequestModel(name: "")) 38 func createContact(_ data: ContactRequestModel) async -> Result<Bool, ContactError> { 39 createContactGotCalledWith = data 40 return createContactResult; 41 } 42 43}

and then our use case code to make the tests pass

1//Contacts/Domain/UseCases/GetAllContacts.swift 2import Foundation 3class GetAllContacts : GetAllContactsUseCaseProtocol{ 4 5 private let contactRepo: ContactRepositoryProtocol 6 7 init(contactRepo: ContactRepositoryProtocol){ 8 self.contactRepo = contactRepo 9 } 10 11 func execute() async -> Result<[ContactResponseModel], ContactError> { 12 return await contactRepo.getContacts() 13 } 14 15} 16
1//Contacts/Domain/UseCases/DeleteContact.swift 2import Foundation 3class DeleteContact : DeleteContactUseCaseProtocol{ 4 private let contactRepo: ContactRepositoryProtocol 5 6 init(contactRepo: ContactRepositoryProtocol){ 7 self.contactRepo = contactRepo 8 } 9 10 func execute(_ id: UUID) async -> Result<Bool, ContactError> { 11 return await self.contactRepo.deleteContact(id) 12 } 13 14}

You get the picture! To follow further, checkout the GitHub which includes tests for all testable components.

Check out the GitHub repo here