Architecture
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.
| Layer | Job | Example |
|---|---|---|
| UI | Draw state, forward user actions | FeedView |
| View model | Hold screen state, call the layer below | FeedViewModel |
| Use case | One business action with real rules | LoadFeedUseCase |
| Repository | Decide where data comes from: memory, disk or network | FeedRepository |
| Service | Thin wrapper over a system API | HTTPClient, 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
| Way | When to use |
|---|---|
Through init | The default. Clear, checked by the compiler. |
| Property injection | When you do not control init, like storyboard view controllers. |
| SwiftUI environment | Values 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 ModelsRules 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"]),
]
)| Pros | Cons |
|---|---|
| Faster builds, only changed modules rebuild | More setup and more files |
| Clear ownership for each team | Shared types need a home (a Models module) |
| Forced boundaries, no hidden dependencies | Too 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."
Navigation
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
| Pattern | Good for | Watch out for |
|---|---|---|
| MVC | Small apps, quick prototypes | "Massive view controller" |
| MVVM | Most apps, works well with SwiftUI | View models that grow too big |
| VIPER | Very large UIKit teams | Lots of boilerplate |
| TCA (The Composable Architecture) | Teams that want strict, testable state | Learning 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
@MainActorand hold screen state. Repositories are the single source of truth and hide the network and the disk. Every dependency is a protocol, injected throughinitfrom one composition root. So each layer can be tested with fakes."