Part four of the microapps series keeps low-level code out of feature modules. A feature module
declares the exact functions it needs as a Dependencies struct of closures, and the app
container fulfils them. That keeps modules isolated, testable, and previewable.
Declare Dependencies as Closures
The view model owns a nested Dependencies struct listing only what it uses. Passing a whole
SearchService instance would work, but it exposes every method the service has; closures
expose exactly two.
@MainActor public final class SearchViewModel: ObservableObject {
public struct Dependencies {
var search: (String) async throws -> [String]
var fetchRecent: () async throws -> [String]
}
let dependencies: Dependencies
public init(dependencies: Dependencies) {
self.dependencies = dependencies
}
@Published private(set) var items: [String] = []
@Published private(set) var recent: [String] = []
func search(matching query: String) async {
do {
items = try await dependencies.search(query)
} catch {
items = []
}
}
}
Each module stays free to pick its own internal architecture: MVVM here, unidirectional flow in another module.
Mocks for Tests and Previews
Because dependencies are plain closures, a mock is a two-line extension, and Xcode previews run without any real service.
extension SearchViewModel.Dependencies {
static let mock: Self = .init(
search: { _ in ["Search Item 1", "Search Item 2"] },
fetchRecent: { ["query1", "query2"] }
)
}
struct SearchView_Previews: PreviewProvider {
static var previews: some View {
SearchView(viewModel: .init(dependencies: .mock))
}
}
One Container in the App Target
The app target holds the real services in one AppDependencies container, stored in the
AppDelegate or the root view. Computed properties slice it into each feature's
Dependencies, wiring service methods to the closures.
struct AppDependencies {
static let production = Self(
searchService: SearchService(),
storage: Storage()
)
let searchService: SearchService
let storage: Storage
}
extension AppDependencies {
var search: SearchViewModel.Dependencies {
.init(
search: searchService.search,
fetchRecent: searchService.fetchRecent
)
}
}
struct RootView: View {
@StateObject var viewModel = SearchViewModel(
dependencies: AppDependencies.production.search
)
var body: some View {
SearchView(viewModel: viewModel)
}
}
Add one computed slice per feature module; the modules never learn which concrete types sit behind their closures.