Most SwiftUI tutorials stop at the view. That is fine for a demo, but a real app picks up networking, persistence, auth, and business rules along the way. If all of that lives next to your body, every change turns into a negotiation with the UI.

On WRDB, the iOS app I build as CTO, I use a Clean Architecture split: Domain, UseCases, Data, Presentation. The point is not to add folders. It is to make the dependencies point in one direction, so the rules that matter do not depend on the details that change.

The four layers

  • Domain. Plain Swift models and protocols. No SwiftUI, no networking, no Supabase. Just the language of the product: an Outfit, a Garment, and the protocols that describe what the app can do.
  • UseCases. One intention per type, like RecommendOutfitUseCase. They orchestrate domain logic and depend only on Domain protocols, never on a concrete data source.
  • Data. The real implementations. Repositories that talk to Supabase, Cloudflare Workers, or the cache, and map raw responses into Domain models.
  • Presentation. SwiftUI views and their view models. They call UseCases and render state. They know nothing about where the data comes from.

The dependency rule is the whole game. Outer layers depend on inner ones, never the other way around. Presentation depends on UseCases, UseCases depend on Domain. Data also depends on Domain, because it implements Domain protocols. Domain depends on nothing.

What a boundary looks like

The Domain declares a capability as a protocol:

protocol ClosetRepository {
    func garments() async throws -> [Garment]
    func add(_ garment: Garment) async throws
}

A UseCase depends on that protocol, not on the network:

struct OrganizeClosetUseCase {
    let repository: ClosetRepository

    func callAsFunction() async throws -> [Garment] {
        try await repository.garments()
    }
}

The Data layer provides the concrete version:

final class SupabaseClosetRepository: ClosetRepository {
    func garments() async throws -> [Garment] {
        // fetch, decode, map the response into Domain models
    }
    func add(_ garment: Garment) async throws { /* ... */ }
}

Presentation never sees SupabaseClosetRepository. It gets a ClosetRepository, which in a test can just as easily be a fake that lives in memory.

Where it earns its keep

Three places, concretely.

  1. Testing. UseCases are pure logic over protocols, so they run without a network or a simulator. That is the difference between tests you run on every save and tests you never run.
  2. Swapping details. Moving an endpoint from a direct call to a Cloudflare Worker touched the Data layer only. The UseCases and the views never knew it happened.
  3. Reading it later. When code is grouped by intention instead of by framework, "where does the outfit recommendation live" has an obvious answer.

The ceremony to skip

Clean Architecture gets a bad name from people who apply all of it, all the time. A protocol for every class, mappers wrapping mappers, four files to add one field. I do not do that. A boundary earns its place when a layer has a real reason to change on its own, like a data source or a business rule. When it does not, I collapse it. A small view with local state does not need a UseCase to hold its hand.

Architecture is a tool for managing change. Add a boundary when something is likely to move on its own schedule. Skip it when it is not. That is the whole heuristic.