iosNudge

Architecture

Chapter 4 of 8 4 min read

The layers

Most good iOS designs use the same five layers. Draw them like this:

+-----------------------------------------------------+
| UI             SwiftUI views / UIViewControllers     |
+-----------------------------------------------------+
| Presentation   View models: screen state + actions   |
+-----------------------------------------------------+
| Domain         Use cases: business rules (optional)  |
+-----------------------------------------------------+
| Data           Repositories: one source of truth     |
+-----------------------------------------------------+
| Services       HTTP client, database, image cache,   |
|                Keychain, push, analytics             |
+-----------------------------------------------------+
        Each layer only talks to the layer below it,
        and only through a protocol.

The key rule: dependencies point down. A view never calls URLSession. A repository never touches a view. This makes each part easy to test and easy to replace.

LayerJobExample
UIDraw state, forward user actionsFeedView
View modelHold screen state, call the layer belowFeedViewModel
Use caseOne business action with real rulesLoadFeedUseCase
RepositoryDecide where data comes from: memory, disk or networkFeedRepository
ServiceThin wrapper over a system APIHTTPClient, KeychainStore

UI layer

The view shows state and forwards taps. It has no business logic.

struct FeedView: View {
    @State var viewModel: FeedViewModel

    var body: some View {
        List(viewModel.posts) { post in
            PostRow(post: post)
        }
        .task { await viewModel.load() }
        .refreshable { await viewModel.load() }
    }
}

View model

The view model owns the screen state. It is @MainActor because it drives the UI. All its state changes happen on the main thread, and the compiler checks this for you.

@MainActor
@Observable
final class FeedViewModel {
    enum State: Equatable { case loading, loaded, empty, failed(String) }

    private(set) var posts: [Post] = []
    private(set) var state: State = .loading
    private let loadFeed: LoadFeedUseCase

    init(loadFeed: LoadFeedUseCase) {
        self.loadFeed = loadFeed
    }

    func load() async {
        do {
            posts = try await loadFeed.execute()
            state = posts.isEmpty ? .empty : .loaded
        } catch {
            state = .failed("Could not load the feed.")
        }
    }
}

Use cases

A use case is one business action. Add it only when there is real logic. Example: hide posts from users that the current user has blocked.

struct LoadFeedUseCase: Sendable {
    let feed: FeedRepository
    let blockList: BlockListRepository

    func execute() async throws -> [Post] {
        async let page = feed.fetchFeed(cursor: nil)        // both requests
        async let blocked = blockList.blockedUserIDs()      // run in parallel
        let (posts, hidden) = try await (page.posts, blocked)
        return posts.filter { !hidden.contains($0.author.id) }
    }
}

For a small app, say so: "Here the view model can call the repository directly. I'd add use cases when the logic grows." This is a good trade-off answer, not a weakness.

Repository

A repository is the single source of truth for one kind of data. It hides where data comes from. The view model does not care if posts came from disk or from the network.

protocol FeedRepository: Sendable {
    func fetchFeed(cursor: String?) async throws -> FeedPage
}

final class DefaultFeedRepository: FeedRepository {
    private let api: FeedAPI
    private let store: FeedStore

    init(api: FeedAPI, store: FeedStore) {
        self.api = api
        self.store = store
    }

    func fetchFeed(cursor: String?) async throws -> FeedPage {
        do {
            let page = try await api.fetchFeed(cursor: cursor, limit: 20)
            if cursor == nil { try await store.replaceAll(page.posts) }   // save the first page
            return page
        } catch {
            // Offline? For the first page, fall back to the copy on disk.
            guard cursor == nil else { throw error }
            let saved = try await store.loadAll()
            if saved.isEmpty { throw error }
            return FeedPage(posts: saved, nextCursor: nil)
        }
    }
}

Services

Services are thin wrappers around system APIs. Put a protocol in front of each one, so tests can use a fake.

protocol HTTPClient: Sendable {
    func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
}

struct URLSessionHTTPClient: HTTPClient {
    let session: URLSession

    func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
        let (data, response) = try await session.data(for: request)
        guard let http = response as? HTTPURLResponse else {
            throw URLError(.badServerResponse)
        }
        return (data, http)
    }
}

Dependency injection

Dependency injection (DI) means: an object gets what it needs from outside. It does not create it inside.

Hard to test, because the dependency is hidden:

final class ProfileViewModel {
    func load() async throws -> Profile {
        try await APIClient.shared.profile()   // always the real network
    }
}

Easy to test, because the dependency comes in through init:

final class ProfileViewModel {
    private let api: ProfileAPI

    init(api: ProfileAPI) {
        self.api = api
    }

    func load() async throws -> Profile {
        try await api.profile()
    }
}

Now a test can pass a fake:

struct FakeProfileAPI: ProfileAPI {
    var result: Result<Profile, any Error>
    func profile() async throws -> Profile { try result.get() }
}

let viewModel = ProfileViewModel(api: FakeProfileAPI(result: .success(Profile(name: "Ana"))))

The composition root

Build all objects in one place, near app start. This place is called the composition root. Nothing else creates services.

@MainActor
final class AppContainer {
    let http: HTTPClient = URLSessionHTTPClient(session: .shared)
    lazy var feedAPI: FeedAPI = RemoteFeedAPI(http: http)
    lazy var feedStore: FeedStore = FileFeedStore()
    lazy var feedRepository: FeedRepository = DefaultFeedRepository(api: feedAPI, store: feedStore)
    lazy var blockList: BlockListRepository = RemoteBlockListRepository(http: http)

    func makeFeedViewModel() -> FeedViewModel {
        FeedViewModel(loadFeed: LoadFeedUseCase(feed: feedRepository, blockList: blockList))
    }
}

lazy means each service is created only when first used. This also helps launch time.

Ways to inject

WayWhen to use
Through initThe default. Clear, checked by the compiler.
Property injectionWhen you do not control init, like storyboard view controllers.
SwiftUI environmentValues many views need, like an image loader or a theme.
DI library (a container)Very large apps. Less boilerplate, but errors show up at runtime, not at compile time.

The SwiftUI environment looks like this:

extension EnvironmentValues {
    @Entry var imageLoader: any ImageLoading = DefaultImageLoader()
}

struct Avatar: View {
    @Environment(\.imageLoader) private var imageLoader
    let url: URL
    var body: some View { Color.gray }   // would ask imageLoader for the image
}

Modules

For a big app and many teams, split the code into Swift packages (modules).

                    App (composition root)
                /            |            \
       FeedFeature    ProfileFeature    ChatFeature
                \            |            /
     Core:  Networking   Persistence   DesignSystem   Analytics   Models

Rules to say out loud:

  • Feature modules do not import each other. They talk through small protocols or routes.
  • Core modules know nothing about features.
  • Each module has its own tests and previews.
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "AppModules",
    platforms: [.iOS(.v17)],
    products: [
        .library(name: "FeedFeature", targets: ["FeedFeature"]),
    ],
    targets: [
        .target(name: "Models"),
        .target(name: "Networking", dependencies: ["Models"]),
        .target(name: "DesignSystem"),
        .target(name: "FeedFeature", dependencies: ["Models", "Networking", "DesignSystem"]),
        .testTarget(name: "FeedFeatureTests", dependencies: ["FeedFeature"]),
    ]
)
ProsCons
Faster builds, only changed modules rebuildMore setup and more files
Clear ownership for each teamShared types need a home (a Models module)
Forced boundaries, no hidden dependenciesToo many tiny modules slow everyone down

For a team of two, say: "One app target with folders is enough. I'd split modules when the team or build times grow."

Keep navigation out of views and view models. A router (or a coordinator in UIKit) decides which screen comes next.

enum Route: Hashable {
    case post(id: String)
    case profile(userID: String)
}

@MainActor
@Observable
final class Router {
    var path: [Route] = []
    func show(_ route: Route) { path.append(route) }
    func popToRoot() { path.removeAll() }
}

// In the root view: NavigationStack(path: $router.path) { ... }

This also makes deep links easy. A link like myapp://post/42 becomes router.show(.post(id: "42")).

Which pattern to name

PatternGood forWatch out for
MVCSmall apps, quick prototypes"Massive view controller"
MVVMMost apps, works well with SwiftUIView models that grow too big
VIPERVery large UIKit teamsLots of boilerplate
TCA (The Composable Architecture)Teams that want strict, testable stateLearning curve, a third-party library

Name a pattern, but focus on the boundaries. Interviewers care more about clear layers and testability than about a specific acronym.

What to say

"I'll use MVVM with repositories. Views only render state. View models are @MainActor and hold screen state. Repositories are the single source of truth and hide the network and the disk. Every dependency is a protocol, injected through init from one composition root. So each layer can be tested with fakes."