Skip to Content
Course content

147: Clean Architecture Layering in iOS Apps

Click on the "Edit" button in the top corner of the screen to edit your slide content.

By now, you've probably seen plenty of "MVVM" tutorials. But as your apps grow, you'll find that ViewModels often become "dumping grounds" for everything from API calls to complex business calculations. That's where Clean Architecture comes in. The goal isn't to add more folders for the sake of it; it's to ensure that your business logic doesn't care whether you're fetching data from a JSON API, a Firebase database, or a local plist file.

Let's build a simple "Current Weather" feature together. We're going to separate this into three distinct layers: Domain, Data, and Presentation.

Defining the Core Business Logic (Domain)

In Clean Architecture, we start from the inside out. The Domain layer is the heart of the app. It should be pure Swift—no UIKit, no SwiftUI, and definitely no networking libraries. If we decided to move this logic to a command-line tool tomorrow, the Domain layer shouldn't have to change.

// The Entity: A simple model that the rest of the app understands
struct Weather {
    let temperature: Double
    let condition: String
}

// The Repository Interface: We define WHAT we need, not HOW we get it
protocol WeatherRepository {
    func fetchCurrentWeather(for city: String) async throws -> Weather
}

// The Use Case: A single, specific business action
class GetWeatherUseCase {
    private let repository: WeatherRepository

    init(repository: WeatherRepository) {
        self.repository = repository
    }

    func execute(city: String) async throws -> Weather {
        // This is where you'd put business rules (e.g., validation, caching logic)
        return try await repository.fetchCurrentWeather(for: city)
    }
}

Wiring up the Data Source (Data)

Now we move to the Data layer. This is where the "dirty" work happens. This layer implements the protocols defined in the Domain. It knows about URLs, JSON decoding, and API keys.

// A DTO (Data Transfer Object) that matches the API response
struct WeatherResponse: Decodable {
    let temp: Double
    let desc: String
}

class WeatherRepositoryImpl: WeatherRepository {
    func fetchCurrentWeather(for city: String) async throws -> Weather {
        let url = URL(string: "https://api.weather.com/v1/\(city)")!
        let (data, _) = try await URLSession.shared.data(from: url)
        let response = try JSONDecoder().decode(WeatherResponse.self, from: data)
        
        // Map the API model to the Domain entity
        return Weather(temperature: response.temp, condition: response.desc)
    }
}

Connecting to the UI via the ViewModel (Presentation)

Finally, the Presentation layer. The ViewModel doesn't talk to the Repository directly; it talks to the Use Case. This keeps the ViewModel slim and focused on formatting data for the view.

@MainActor
class WeatherViewModel: ObservableObject {
    @Published var weatherDescription: String = ""
    
    private let getWeatherUseCase: GetWeatherUseCase

    init(getWeatherUseCase: GetWeatherUseCase) {
        self.getWeatherUseCase = getWeatherUseCase
    }

    func updateWeather(for city: String) async {
        do {
            let weather = try await getWeatherUseCase.execute(city: city)
            self.weatherDescription = "\(weather.temperature)°C and \(weather.condition)"
        } catch {
            self.weatherDescription = "Error loading weather"
        }
    }
}

Fixing a Leaky Abstraction

I'll be honest: when I first started implementing this pattern, I used to take shortcuts. I remember one project where I got lazy and passed the WeatherResponse (the API model) directly from the Repository all the way up to the ViewModel just to avoid writing the mapping code.

It seemed fine at first, but then the API provider changed the field temp to temperature_celsius. Because I leaked the Data model into the Presentation layer, I had to go into my ViewModel and my SwiftUI views to fix the naming. I had broken the fundamental rule of layering: The outer layers can depend on the inner layers, but the inner layers must never know about the outer layers.

If you find yourself importing your Networking module into your ViewModel, stop. Map that API response to a Domain entity inside your Repository. It feels like extra work now, but it saves you from a massive refactor when the backend team decides to change their JSON structure next month.




📋 Practical Task

Refactor the UserProfile Feature to Clean Architecture

You have a legacy UserProfileViewModel that currently handles networking directly using a UserAPIClient. The ViewModel is using a UserJSONResponse model which contains raw API fields like user_first_name and user_last_name.

Your task is to decouple this using the layering we discussed:

  • Create a Domain Entity: Create a UserProfile struct with clean properties (e.g., firstName and lastName).
  • Define a Repository Protocol: Create a UserRepository protocol in the Domain layer that returns the UserProfile entity.
  • Implement the Data Layer: Create a UserRepositoryImpl that calls the UserAPIClient and maps the UserJSONResponse into the UserProfile entity.
  • Create a Use Case: Implement a GetUserProfileUseCase that coordinates the repository.
  • Update the ViewModel: Refactor the UserProfileViewModel to depend on the GetUserProfileUseCase instead of the API client.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.