iOS Engineering

[iOS Architecture #6] Clean Architecture Essentials

You often see “based on Clean Architecture” in job postings and technical blogs. But when you open the code, every team has a different interpretation. Some use UseCase, others do not, and Repository responsibilities vary as well.

4 min read
Cover image for [iOS Architecture #6] Clean Architecture Essentials

You often see “based on Clean Architecture” in job postings and technical blogs. But when you open the code, every team has a different interpretation. Some use UseCase, others do not, and Repository responsibilities vary as well.

The reason for the confusion is simple: Clean Architecture is a principle, not a specific folder structure or list of classes.

Today, we’ll clarify what that principle is and what UseCase and Repository each handle when implemented in iOS.


The key is “dependencies point inward only”

Clean Architecture is a concept organized by Robert Martin (Uncle Bob), which views an app as a set of concentric layers.

  • Innermost: Domain. Entities and business rules. “What does this app do?”
  • Middle: UseCase. The app’s behavior scenarios
  • Outermost: UI, databases, networks, and frameworks

There is only one rule. Dependencies always point from the outside inward.

Code in the inner domain must know nothing about outer UIKit, URLSession, or SwiftData. The outer layers may know about the inner ones. This lets you replace the UI framework or change the server API without touching the app’s core rules.

As you may have noticed, this extends DIP (the Dependency Inversion Principle) to the scale of the entire app. “Depend on abstractions, not concretions” is applied layer by layer.


Repository: the boundary that hides “where data comes from”

Repository is the boundary between the domain and data sources. Put the protocol on the domain side and the implementation on the outside.

// Domain layer — URLSessionknows SwiftDatanothing
protocol PostRepository {
    func fetchPosts(userID: String) async throws -> [Post]
}

// Data layer — implementation stays outside
final class RemotePostRepository: PostRepository {
    func fetchPosts(userID: String) async throws -> [Post] {
        // URLSession call, DTO decode, Postconvert
    }
}

The key is where the protocol lives. Because the domain owns the interface, it only knows that “posts can be fetched”; it does not know whether they come from a server, cache, or local DB. If the server response format changes, update only the DTO (Data Transfer Object, the object that carries server responses) and its implementation. In tests, plug in a fake Repository.

The domain does not need to know where data comes from
The domain does not need to know where data comes from

UseCase: a container for one thing “this app does”

A UseCase turns one app behavior scenario into an object, such as “load the feed” or “publish a post.”

final class LoadFeedUseCase {
    private let postRepository: PostRepository
    private let blockRepository: BlockRepository

    func execute(userID: String) async throws -> [Post] {
        let posts = try await postRepository.fetchPosts(userID: userID)
        let blocked = try await blockRepository.blockedUserIDs()
        return posts
            .filter { !blocked.contains($0.authorID) }
            .sorted { $0.createdAt > $1.createdAt }
    }
}

The business rule “exclude posts from blocked users from the feed” belongs in UseCase. What happens if you put it in ViewModel? The feed, profile, and search screens each implement their own blocking filter, and eventually one of them drifts. Extract it into UseCase, and the rule lives in one place and can be tested without a screen.

That is why MVVM (Model-View-ViewModel) and Clean Architecture are not competing approaches but orthogonal ones. MVVM organizes the View side, while Clean Architecture organizes the layers behind it. In the previous article, I said to “push logic down to avoid a Massive ViewModel”; UseCase and Repository are where that logic goes.


How far should you take it?

The trap of Clean Architecture is over-adoption. If you add UseCase, Repository, DTO, and Mapper to a two-screen app, passing one value takes five files. You recreate the boilerplate problem of VIPER (View·Interactor·Presenter·Entity·Router).

A practical guideline is this.

  • Repository is almost always worthwhile. Simply separating network and DB code from screens makes testing and change easier.
  • Add UseCase when business rules shared by multiple screens emerge. If most UseCase objects merely forward Repository calls, it is too early.
  • Separate models by layer (DTO/domain/screen models) after the project grows. Splitting everything from the start only piles up Mapper code.
Build it one layer at a time when needed, rather than adding everything
Build it one layer at a time when needed, rather than adding everything

Summary

  • Clean Architecture is not a folder structure but the principle that “dependencies point inward (toward the domain) only.” It is DIP scaled up to the app level.
  • Repository is the boundary that hides the data source, and the key point is that the domain owns the protocol.
  • UseCase is where business rules shared by multiple screens belong. It is orthogonal to MVVM, so they are used together.
  • The goal is not to include everything. Start with Repository, then add UseCase as rules accumulate.

Next time, we’ll change direction and cover TCA (The Composable Architecture), which turns state management itself into an architecture.