A scalable iOS architecture is not about creating the largest number of folders. It is about making change predictable: business rules remain testable, screens remain focused, and infrastructure can evolve without rewriting the product.

Start with change, not patterns

Architecture should answer a simple question: what changes independently? UI changes frequently, APIs evolve, storage technologies are replaced, and business rules continue to grow. When those concerns are mixed together, a small product request can affect the entire codebase.

Before choosing patterns, map the product’s core flows, boundaries and dependencies. A travel app, for example, may have search, booking, payment and post-booking features. Each feature can own its presentation and orchestration while sharing stable domain concepts such as money, travelers and reservation identifiers.

Use three clear responsibility layers

PresentationViews, view models, navigation and UI state.DomainBusiness rules, entities, use cases and repository contracts.DataAPI clients, persistence, DTO mapping and repository implementations.

The important rule is dependency direction. Presentation may depend on domain abstractions, and data may implement domain contracts. The domain layer should not know about SwiftUI, UIKit, URLSession, Realm or a specific payment SDK.

Model use cases around user intent

A use case should describe an action the product performs rather than the technology used to perform it. Names such as SearchFlights, LoadAppointments or ConfirmReservation communicate intent and keep view models small.

protocol SearchTripsUseCase {
    func execute(_ request: TripSearchRequest) async throws -> TripSearchResult
}

final class DefaultSearchTripsUseCase: SearchTripsUseCase {
    private let repository: TripRepository

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

    func execute(_ request: TripSearchRequest) async throws -> TripSearchResult {
        try await repository.search(request)
    }
}

This boundary also makes testing straightforward: the view model can receive a fake use case, while the domain behavior can be tested without rendering a screen.

Treat mapping as a first-class concern

Network responses and database models are infrastructure details. Map them into domain models at the data boundary instead of allowing DTOs to travel throughout the app. This protects the rest of the product when a backend field changes or a persistence framework is replaced.

  • Keep API response models close to the API client.
  • Keep persistence entities close to the storage implementation.
  • Expose stable domain models to the rest of the application.
  • Centralize date, currency and error mapping rules.

Design UI state explicitly

Production screens rarely have only “loading” and “loaded” states. They may contain stale content, retryable errors, empty results, partial updates and payment transitions. Model these states explicitly so rendering is deterministic.

enum SearchViewState {
    case idle
    case loading(previous: [Trip])
    case loaded([Trip])
    case empty
    case failed(message: String, canRetry: Bool)
}

Explicit state reduces scattered Boolean flags and makes edge cases easier to test.

Scale by feature, not by file type

As the app grows, prefer feature modules or feature folders over one global directory for every view model, repository and service. A feature should be understandable in isolation and expose only the interfaces other features actually need.

Do not modularize everything on day one. Start with boundaries in the source tree, measure build and ownership problems, then extract modules where independence provides a real benefit.

A practical migration strategy

  1. Choose one frequently changed feature.
  2. Define its domain models and repository contract.
  3. Move API and persistence details behind the repository.
  4. Extract use cases from the view model.
  5. Add tests around the new boundaries.
  6. Repeat when another feature needs meaningful change.

This incremental approach lowers risk and proves the architecture with real product work instead of a large rewrite.

Final checklist

  • Business rules can be tested without UIKit or SwiftUI.
  • API and storage models do not leak into views.
  • View models coordinate UI state rather than implement networking.
  • Dependencies point toward stable abstractions.
  • Features have clear ownership and small public surfaces.
  • The architecture makes common changes easier, not slower.
AE

Amgad ElNezamy

iOS engineer working across SwiftUI, UIKit, architecture, payments and production mobile product delivery.

Back to all articles