The Clean Architecture for Swift

Ege Sucu
9 min read5 days ago

--

Photo by Hal Gatewood on Unsplash

The Clean architecture came out in 2012 by Robert C. Martin to solve the problem with project designs, where he believed a project should:

  • Be independent of frameworks
  • Be testable & maintainable
  • Separate of concerns-driven
  • Highly decoupled

Today, we will dive on how a swift project can be supported by this architecture.

Design

On the foundation, The Clean Architecture separates the project into layers. Each layer is responsible for a specific task & have a clear dependency rule. Inner layers do represent high level business rules, while outer layers represent low level implementation details such as UI, DB and others. Inner layers do not know anything about outer layers. This helps developers to implement new features without breaking the core layer of the app and be able to test things on their own sub packages.

These layers are break down into four parts:

  • Interface Adapters → Convert data came by use cases to feed the UI
  • Use Cases → App Specific business rules
  • Entities → Enterprise-wide business
  • Frameworks & Drivers → UI, Database, external frameworks

In SwiftUI’s terms, those will be ViewModel, Classes, Structs & SwiftUI Views/SwiftData(or CoreData) models.

Implementation

For a showcase, I have created a humor app which is responsible to show a random meme and a random joke in the app’s foreground. It also has a search joke by keywords feature in which we can execute a search on the Humor API. Source codes are available on GitHub, and it is written with Swift 6 to showcase current Swift Structure practices with The Clean Architecture.

Entities

With this layer, I have implemented necessary models and enums that will be widely used inside of the app.

struct Joke: Identifiable, Codable {
let id: Int
let joke: String
}
enum APIErrors: Error {
case invalidURL
case invalidResponse
case decodingFailed
case emptySearchQuery
}

extension APIErrors: LocalizedError {
var errorDescription: String? {
switch self {
case .invalidURL:
"The given URL is invalid"
case .invalidResponse:
"The response is not valid."
case .decodingFailed:
"Decoding has failed."
case .emptySearchQuery:
"The search query is empty."
}
}
}

Repositories

This layer covers a repository which handles various functions that we will use. Note that, there is no implementation or a dependency on the repository itself. It will conform into many other use cases.

protocol HumorRepository: Sendable {

func searchJokes(keywords: String, number: Int) async throws -> [Joke]

func randomJoke() async throws -> Joke

func randomMeme() async throws -> Meme
}

Implementations

This is the place where this repository will be conform to. While you could write something like “HumorRepositoryImpl.” or similar, I have opted for “DefaultHumorRepository” which is more clear way to represent the implementation.

final class DefaultHumorRepository: HumorRepository {
private let apiKey: String?
private let baseURL: String

init() {
self.apiKey = Self.loadAPIKey()
self.baseURL = "https://api.humorapi.com"
}

/// This is a test case init, which uses apiKey to intentionally make apiKey nil
init(
fakeApiKey: String = "",
fakeBaseURL: String = ""
) {
if fakeApiKey.isNotEmpty {
self.apiKey = nil
self.baseURL = "https://api.humorapi.com"
} else if fakeBaseURL.isNotEmpty {
self.baseURL = ""
self.apiKey = Self.loadAPIKey()
} else {
self.apiKey = Self.loadAPIKey()
self.baseURL = "https://api.humorapi.com"
}
}

private static func loadAPIKey() -> String? {
guard let fileURL = Bundle.main.url(forResource: "api_key", withExtension: "txt"),
let key = try? String(contentsOf: fileURL, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines),
!key.isEmpty else {
return nil
}
return key
}



func searchJokes(keywords: String, number: Int) async throws(APIErrors) -> [Joke] {
guard keywords.trimmingCharacters(in: .whitespacesAndNewlines).isNotEmpty else {
print("Search query is empty, skipping the search")
throw .emptySearchQuery
}
let searchQuery = keywords.replacingOccurrences(of: " ", with: ",")

guard let apiKey,
let url = URL(string: "\(baseURL)/jokes/search?api-key=\(apiKey)&number=\(number)&keywords=\(searchQuery)") else {
throw .invalidURL
}

do {
let (data, _) = try await URLSession.shared.data(from: url)

do {
let searchResponse = try JSONDecoder().decode(SearchResponse.self, from: data)
return searchResponse.jokes
} catch {
print("Decoding has failed: \(error)")
throw APIErrors.decodingFailed
}

} catch {
throw .invalidResponse
}
}

func randomJoke() async throws(APIErrors) -> Joke {
guard let apiKey,
let url = URL(string: "\(baseURL)/jokes/random?api-key=\(apiKey)") else {
throw .invalidURL
}

do {
let (data, _) = try await URLSession.shared.data(from: url)
do {
return try JSONDecoder().decode(Joke.self, from: data)
} catch {
print("Decoding has failed: \(error)")
throw APIErrors.decodingFailed
}

} catch {
throw .invalidResponse
}
}

func randomMeme() async throws(APIErrors) -> Meme {
guard let apiKey,
let url = URL(string: "\(baseURL)/memes/random?api-key=\(apiKey)") else {
throw .invalidURL
}
do {
let (data, _) = try await URLSession.shared.data(from: url)
do {
return try JSONDecoder().decode(Meme.self, from: data)
} catch {
print("Decoding has failed: \(error)")
throw APIErrors.decodingFailed
}

} catch {
throw .invalidResponse
}
}
}

Use Cases

This layer is supposed to have different scenarios that we use for the repository implementation. We have three cases on this example.

struct SearchJokeUseCase {

private let repository: any HumorRepository

struct Constants {
static let defaultNumberOfJokes: Int = 10
}

init(repository: any HumorRepository) {
self.repository = repository
}

func execute(searchTerms: String, number: Int = Constants.defaultNumberOfJokes) async throws -> [Joke] {
try await repository.searchJokes(keywords: searchTerms, number: number)
}
}
import Foundation

struct RandomJokeUseCase {
private let repository: any HumorRepository

init(repository: any HumorRepository) {
self.repository = repository
}

func execute() async throws -> Joke {
try await repository.randomJoke()
}
}

ViewModel

This layer is pretty familiar with SwiftUI developers, as this is the bridge(or ViewController for UIKit devs) between data & UI. What’s different with TCA is, we are not reaching for Repositories, but for use cases which is needed for that specific view model.

import SwiftUI
import Observation

@MainActor
@Observable
class HumorViewModel {
var searchQuery: String = ""
var jokes: [Joke] = []
var joke: Joke? = nil
var meme: Meme? = nil
var isLoading: Bool = false
var errorMessage: String? = nil

private let searchJokeUseCase: SearchJokeUseCase
private let randomMemeUseCase: RandomMemeUseCase
private let randomJokeUseCase: RandomJokeUseCase

init(
randomJokeUseCase: RandomJokeUseCase,
searchJokeUseCase: SearchJokeUseCase,
randomMemeUseCase: RandomMemeUseCase
) {
self.randomJokeUseCase = randomJokeUseCase
self.searchJokeUseCase = searchJokeUseCase
self.randomMemeUseCase = randomMemeUseCase
}

func searchJokes() async {
do {
async let jokesResult = searchJokeUseCase.execute(searchTerms: searchQuery)
jokes = try await jokesResult
} catch {
print(error.localizedDescription)
errorMessage = error.localizedDescription
}
}

@Sendable func loadRandomData() async {
isLoading = true
errorMessage = nil
do {
async let jokeResult = randomJokeUseCase.execute()
async let memeResult = randomMemeUseCase.execute()

joke = try await jokeResult
meme = try await memeResult
isLoading = false
} catch {
isLoading = false
errorMessage = error.localizedDescription
}
}
}

View

This is the obvious last layer that will be showcased on our app. The point is, the only knowledge of this layer is the VM, and not others.

import SwiftUI
import Observation

struct HumorView: View {
@State var viewModel = HumorViewModel(
randomJokeUseCase: RandomJokeUseCase(repository: DefaultHumorRepository()),
searchJokeUseCase: SearchJokeUseCase(repository: DefaultHumorRepository()),
randomMemeUseCase: RandomMemeUseCase(repository: DefaultHumorRepository())
)

var jokeOfDay: some View {
Group {
Text("Joke of the Day")
.font(.title)
VStack(alignment: .center) {
if let joke = viewModel.joke?.joke {
Text(joke)
.jokeStyle()
} else {
Text("No Joke For you :( ")
.multilineTextAlignment(.center)
}
}
}
}

var memeOfDay: some View {
Group {
Text("Meme of the Day")
.font(.title)

if let meme = viewModel.meme,
let memeURL = meme.url {
AsyncImage(url: memeURL) { phase in
if let image = phase.image {
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxHeight: 300)
.clipShape(.rect(cornerRadius: 10))
.shadow(radius: 4)
} else if phase.error != nil {
Text("Error loading meme")
} else {
ProgressView()
}
}
.padding()
}
}
}

var searchJokeView: some View {
Group {
TextField("Enter search query...", text: $viewModel.searchQuery)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()

Button("Search") {
Task {
await viewModel.searchJokes()
}
}
.buttonStyle(.borderedProminent)
.padding()

if viewModel.isLoading {
ProgressView("Loading...")
} else if let error = viewModel.errorMessage {
Text("Error: \(error)")
.foregroundColor(.red)
} else {
ForEach(viewModel.jokes) { joke in
VStack(alignment: .center) {
Text(joke.joke)
.jokeStyle()
.padding()
}
}
}
}
}

var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
jokeOfDay

memeOfDay

searchJokeView
}
.padding()
}
.navigationTitle("Humors")
}
.task(viewModel.loadRandomData)
}
}

Testability

Since TCA aims to be testable, we can create some mock cases to test for Unit/UI vise.

Here, we can create a MockRepo:

import Foundation

final class MockHumorRepository: HumorRepository {

func searchJokes(keywords: String, number: Int) async throws -> [Joke] {
return (1...number).map { number in
Joke(id: number, joke: "Mock joke \(number) for keyword '\(keywords)'")
}
}

func randomJoke() async throws -> Joke {
return Joke(id: 999, joke: "Why don't skeletons fight each other? Because they don't have the guts.")
}

func randomMeme() async throws -> Meme {
return Meme(
id: 123,
urlString: "https://i.imgflip.com/1bij.jpg", // Sample meme image URL
type: "meme"
)
}
}

And we can use this on our Views via #Preview

#Preview {
let mockRepo = MockHumorRepository()
let viewModel = HumorViewModel(
randomJokeUseCase: RandomJokeUseCase(repository: mockRepo),
searchJokeUseCase: SearchJokeUseCase(repository: mockRepo),
randomMemeUseCase: RandomMemeUseCase(repository: mockRepo)
)
HumorView(viewModel: viewModel)
}

Also, we can use the Swift Testing framework for Unit Tests

import Testing
@testable import Humor_TCA
import Foundation

struct HumorViewModelTests {

struct MockUseCases {
struct Success: Sendable {
let search = SearchJokeUseCase(repository: MockHumorRepository())
let joke = RandomJokeUseCase(repository: MockHumorRepository())
let meme = RandomMemeUseCase(repository: MockHumorRepository())
}
}

@Test
@MainActor
func testSearchJokesUpdatesJokesArray() async throws {
let useCases = MockUseCases.Success()
let vm = HumorViewModel(
randomJokeUseCase: useCases.joke,
searchJokeUseCase: useCases.search,
randomMemeUseCase: useCases.meme
)

vm.searchQuery = "test"
await vm.searchJokes()

#expect(vm.jokes.count == 10)
#expect(vm.jokes[0].joke == "Mock joke 1 for keyword 'test'")
}

@Test
func testLoadRandomDataSetsJokeAndMeme() async throws {
let useCases = MockUseCases.Success()
let vm = await HumorViewModel(
randomJokeUseCase: useCases.joke,
searchJokeUseCase: useCases.search,
randomMemeUseCase: useCases.meme
)

await vm.loadRandomData()

await #expect(vm.joke?.joke == "Why don't skeletons fight each other? Because they don't have the guts.")
await #expect(vm.meme?.urlString == "https://i.imgflip.com/1bij.jpg")
await #expect(vm.isLoading == false)
await #expect(vm.errorMessage == nil)
}
}

It’s actually pretty easy when you have a mock repo to test anything, use cases too:

import Testing
import Foundation
@testable import Humor_TCA

struct RandomJokeUseCaseTests {
struct MockHumorRepository: HumorRepository {
var jokeToReturn: Joke

func searchJokes(keywords: String, number: Int) async throws -> [Joke] {
[]
}

func randomJoke() async throws -> Joke {
jokeToReturn
}

func randomMeme() async throws -> Meme {
throw NSError(domain: "Not needed", code: 0)
}
}

@Test
func returnsJokeFromRepository() async throws {
// Given
let expected = Joke(id: 123, joke: "Mocked random joke")
let repo = MockHumorRepository(jokeToReturn: expected)
let useCase = RandomJokeUseCase(repository: repo)

// When
let result = try await useCase.execute()

// Then
#expect(result.id == expected.id)
#expect(result.joke == expected.joke)
}

@Test
func throwsIfRepositoryFails() async {
struct FailingRepo: HumorRepository {
func searchJokes(keywords: String, number: Int) async throws -> [Joke] { [] }
func randomJoke() async throws -> Joke {
throw NSError(domain: "test", code: 1)
}
func randomMeme() async throws -> Meme {
throw NSError(domain: "irrelevant", code: 0)
}
}

let useCase = RandomJokeUseCase(repository: FailingRepo())

await #expect(throws: (any Error).self) {
try await useCase.execute()
}
}
}

We can even test the Default Implementation

import Testing
@testable import Humor_TCA

struct DefaultHumorRepositoryTests {
@Test
func searchJokes_withInvalidKeyword_shouldThrowEmptySearchQuery() async {
let repo = DefaultHumorRepository()

await #expect {
try await repo.searchJokes(keywords: " ", number: 3)
} throws: { error in
guard let apiError = error as? APIErrors else { return false }
return apiError == .emptySearchQuery
}
}

@Test
func searchJokes_withInvalidURL_shouldThrowInvalidURL() async {
let repo = DefaultHumorRepository(fakeApiKey: "dfds")

await #expect {
try await repo.searchJokes(keywords: "funny", number: 3)
} throws: { error in
guard let apiError = error as? APIErrors else { return false }
return apiError == .invalidURL
}
}

@Test
func searchJokes_withInvalidURL_showThrowInvalidResponse() async {
let repo = DefaultHumorRepository(fakeBaseURL: "https://www.google.com")

await #expect {
try await repo.searchJokes(keywords: "funny", number: 3)
} throws: { error in
guard let apiError = error as? APIErrors else { return false }
return apiError == .invalidResponse
}
}
}

And that’s it. You can download the source code, add your own api key into api_key.txt file you’ll create and test it out!

While The Clean Architecture has a nice way of maintaining the code in layers, it can bother a simple project with so many setups, hence it is usually recommended an already built mid-to-big size projects, although creating one for our example did not take too much time either.

What do you think about the architecture? Would you be interested in to discuss this with your team, or do you have another opinion on it. Feel free to discuss down below. Happy coding.

Sign up to discover human stories that deepen your understanding of the world.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

No responses yet

Write a response