Architecture With Nico

Practical software architecture and design for iOS engineers.

Use Cases in iOS Apps: Helpful Boundary or Extra Layer?

Use cases can make an iOS codebase clearer. They can also make it heavier for no good reason.

The difference is not the pattern. The difference is whether the use case represents a real application action.

LoginUseCase can be useful. FormatUsernameUseCase probably is not. CheckoutUseCase may be useful. ToggleIsLoadingUseCase is probably noise.

The point of a use case is to name and isolate something the application does.

#What a Use Case Is

A use case is an application operation.

It coordinates domain rules, repositories, policies, and outputs to complete a specific user or system goal.

struct LoginUseCase {
    let authenticationRepository: AuthenticationRepository
    let sessionStore: SessionStore
    let output: LoginUseCaseOutput

    func execute(email: String, password: String) async {
        output.loginDidStart()

        do {
            let session = try await authenticationRepository.login(
                email: email,
                password: password
            )

            try await sessionStore.save(session)
            output.loginDidSucceed()
        } catch {
            output.loginDidFail()
        }
    }
}

This is not just a wrapper around a repository. The use case coordinates the login operation. It talks to authentication, persists the resulting session, and reports a domain-specific output.

That coordination is application logic.

#When a Use Case Helps

A use case is usually helpful when an operation has at least one of these qualities:

  • It coordinates more than one dependency.
  • It contains business or application rules.
  • It is reused by more than one UI.
  • It creates a stable testing boundary.
  • It expresses a meaningful product action.

For example, checkout is often more than one repository call:

struct PlaceOrderUseCase {
    let cartRepository: CartRepository
    let paymentProcessor: PaymentProcessing
    let ordersRepository: OrdersRepository
    let output: PlaceOrderUseCaseOutput

    func execute() async {
        do {
            let cart = try await cartRepository.currentCart()
            let payment = try await paymentProcessor.charge(cart.total)
            let order = try await ordersRepository.createOrder(
                cart: cart,
                payment: payment
            )
            output.placeOrderDidSucceed(order)
        } catch {
            output.placeOrderDidFail()
        }
    }
}

You could put that in a ViewModel, but then the ViewModel becomes responsible for checkout policy. A use case gives the operation a name and a home.

#When a Use Case Is Noise

This is where teams often overcorrect.

struct GetProductNameUseCase {
    func execute(product: Product) -> String {
        product.name
    }
}

This is not buying much. It adds a file, a name, a dependency, and a layer, but not a meaningful boundary.

Another smell is a use case that only forwards to one repository with no added meaning:

struct GetProductsUseCase {
    let repository: ProductsRepository
    let output: ProductsOutput

    func execute() async {
        do {
            output.didLoadProducts(try await repository.getProducts())
        } catch {
            output.didFailLoadingProducts()
        }
    }
}

This can still be reasonable if GetProductsUseCase is the operation boundary you want the feature to trigger. But be honest about the tradeoff. Today it is mostly a pass-through. It may become useful later, but architecture should not be built entirely out of guesses about later.

If the app is small, triggering a repository from a feature adapter can be fine. The important part is not to make the ViewModel responsible for fetching, error policy, and state mapping all at once.

#Use Cases and Presentation Outputs

A ViewModel should not know too much about how an application operation works. In a unidirectional design, it is often better for the ViewModel to receive use case outputs and map them into state.

@MainActor
final class LoginViewModel: ObservableObject, LoginUseCaseOutput {
    @Published private(set) var state: State = .idle

    func loginDidStart() {
        state = .loading
    }

    func loginDidSucceed() {
        state = .loggedIn
    }

    func loginDidFail() {
        state = .failed("Could not log in.")
    }
}

The ViewModel handles presentation state. The use case handles the application operation. A view intent closure, adapter, or feature factory can trigger the use case when the user submits the form.

This split is useful because both sides can change independently. The UI can change how login is presented. The use case can change how login is performed.

#Use Cases and Outputs

Some use cases need to report more than a single success or failure.

In Unidirectional Data Flow in iOS MVP and Clean Architecture, I wrote about using outputs to keep dependencies pointing in one direction. That idea fits use cases well when an operation has multiple meaningful outcomes.

protocol SyncCartUseCaseOutput {
    func syncCartDidStart()
    func syncCartDidFinish()
    func syncCartDidFail(_ error: Error)
}

struct SyncCartUseCase {
    let repository: CartRepository
    let output: SyncCartUseCaseOutput

    func execute() async {
        output.syncCartDidStart()

        do {
            try await repository.sync()
            output.syncCartDidFinish()
        } catch {
            output.syncCartDidFail(error)
        }
    }
}

This is not always necessary for tiny operations, but it is the style I prefer when the use case has meaningful domain outcomes. It keeps business decisions inside the use case and leaves presentation code to map outputs into UI state.

#Use Cases and Decorators

Use cases compose well with decorators.

struct LoggingLoginUseCase: LoginUseCaseProtocol {
    let decoratee: LoginUseCaseProtocol
    let logger: Logger

    func execute(email: String, password: String) async {
        logger.info("login_started")
        await decoratee.execute(email: email, password: password)
    }
}

The core login behavior stays focused. Logging wraps the trigger. Analytics can also be modeled as an output decorator, so success and failure events are tracked without pushing analytics into the ViewModel. This is where Practical Polymorphism in Swift becomes concrete.

#Naming Use Cases

A good use case name should sound like something the app does:

  • LoginUseCase
  • PlaceOrderUseCase
  • LoadProductDetailsUseCase
  • SyncFavoritesUseCase
  • RefreshSessionUseCase

Weak names often expose that the use case is not really a use case:

  • UserUseCase
  • DataUseCase
  • RepositoryUseCase
  • ValidationUseCase

If the name is vague, the responsibility probably is too.

#What You Should Remember

Use cases are helpful when they protect a meaningful application boundary. They are harmful when they become automatic ceremony around every method call.

Do not add a use case because Clean Architecture diagrams have a use case circle. Add one when the operation deserves a name, has rules worth isolating, coordinates dependencies, or gives the UI a better boundary to depend on.

The goal is not more layers. The goal is clearer change.