Dependency Injection in SwiftUI Without the Ceremony

In my previous post, I showed how separating API models from domain models gives you clean, validated data that views can use directly. I mentioned that services would eliminate the need for view models entirely. Let's build that now.

The pattern is simple: closure-based services with observable stores, injected through SwiftUI's environment. No protocols, no view model boilerplate, no dependency injection frameworks. Just Swift.

You can find the complete example project on GitHub.

The Protocol Problem

The traditional approach to dependency injection in Swift involves defining a protocol for every service, then creating separate classes for live and mock implementations. You end up with LandmarkServiceProtocol, LiveLandmarkService, MockLandmarkService, and so on.

This works, but it's verbose. The protocol exists purely to enable testing - it adds no functionality. You're writing three types (protocol plus two implementations) just to be able to swap behaviors. And because Swift protocols can't have stored properties with default implementations, you often end up duplicating state management across your implementations.

When I first encountered the closure-based approach (popularized by Point-Free's dependency management), it felt like a revelation. All that ceremony just... disappears.

Services as Structs with Closures

Instead of a protocol with multiple conforming types, define a single struct where each operation is a closure property:

struct LandmarkService: Sendable {
    let store: LandmarkStore

    var fetchLandmarks: @Sendable () async throws -> Void
    var fetchLandmark: @Sendable (String) async throws -> Landmark?
    var fetchLandmarksByCategory: @Sendable (Category) async throws -> [Landmark]
}

Each closure is a dependency that can be swapped. The struct itself acts as the "protocol" - different instances provide different behavior. Want a mock? Just create an instance with different closures. No new types needed.

This approach has a nice benefit: you can define all your implementations as static properties or factory methods on the same type. A .live version that hits your real API. A .mock version with simulated delays for UI testing. A .preview version with instant data for SwiftUI previews. They're all just different configurations of the same struct.

The Sendable conformance matters here. Services often get passed across actor boundaries - from your main actor UI code to background tasks doing network requests. Making the service Sendable ensures the compiler can verify thread safety.

Where State Lives: Observable Stores

The service struct handles operations, but where does state live? In an observable store that views watch directly.

@MainActor
@Observable
final class LandmarkStore {
    private(set) var landmarks: [Landmark] = []
    private(set) var loadingState: LoadingState<[Landmark]> = .idle

    var featuredLandmarks: [Landmark] {
        landmarks.filter { $0.isFeatured }
    }

    func landmarks(for category: Category) -> [Landmark] {
        landmarks.filter { $0.category == category }
    }

    func setLoading() { loadingState = .loading }
    func setLandmarks(_ landmarks: [Landmark]) {
        self.landmarks = landmarks
        loadingState = .loaded(landmarks)
    }
    func setError(_ error: Error) { loadingState = .failed(error) }
}

The store is marked @MainActor because UI state should live on the main thread. The @Observable macro does the heavy lifting - any view that reads landmarks or loadingState will automatically re-render when those values change.

Notice the private(set) on the stored properties. Views can read the data, but only the store's methods can modify it. This keeps state changes predictable and traceable.

The computed properties like featuredLandmarks are a nice touch. They derive from the source data, so they're always consistent. Views can use them directly without any transformation logic.

This is the key insight: the store is your view model. It holds the observable state that views react to. There's no need for a separate view model class sitting between the service and the view.

Loading State

I like to use a LoadingState enum that captures the full async lifecycle:

enum LoadingState<T: Sendable>: Sendable {
    case idle
    case loading
    case loaded(T)
    case failed(Error)

    var value: T? {
        if case .loaded(let value) = self { return value }
        return nil
    }

    var isLoading: Bool {
        if case .loading = self { return true }
        return false
    }

    var error: Error? {
        if case .failed(let error) = self { return error }
        return nil
    }
}

Views can switch on this state to show the appropriate UI: a spinner while loading, the content when loaded, or an error message if something went wrong. The computed properties make it easy to check specific states without pattern matching everywhere.

The generic parameter means you can reuse this for any async operation - loading landmarks, loading user profiles, loading whatever. One enum handles them all.

Wiring It Up: The Live Implementation

The live implementation is a static factory method that creates a store and returns a service configured with closures that do the real work:

extension LandmarkService {
    static func live(client: NetworkClient, baseURL: URL) -> LandmarkService {
        let store = LandmarkStore()

        return LandmarkService(
            store: store,
            fetchLandmarks: {
                await store.setLoading()

                do {
                    let url = baseURL.appendingPathComponent("landmarks")
                    let response = try await client.fetch(LandmarkListApiResponse.self, from: url)
                    let landmarks = response.items.compactMap(\.domainModel)
                    await store.setLandmarks(landmarks)
                } catch {
                    await store.setError(error)
                    throw error
                }
            },
            fetchLandmark: { id in
                let url = baseURL.appendingPathComponent("landmarks/\(id)")
                let response = try await client.fetch(LandmarkApiModel.self, from: url)
                return response.domainModel
            },
            fetchLandmarksByCategory: { category in
                let url = baseURL.appendingPathComponent("landmarks/category/\(category.rawValue.lowercased())")
                let response = try await client.fetch(LandmarkListApiResponse.self, from: url)
                return response.items.compactMap(\.domainModel)
            }
        )
    }
}

Notice that the store is created inside the factory and captured by the closures. The service owns its store, and the closures update it. This keeps everything nicely encapsulated - there's no way to accidentally create a service pointing at the wrong store.

The mapping from API models to domain models happens here, at the service boundary. That compactMap(\.domainModel) call transforms the API response into validated domain models, filtering out any invalid records. Views never see LandmarkApiModel - they only work with validated Landmark domain models. If you read my previous post, this is where that mapping layer gets used.

The NetworkClient is itself a closure-based dependency. It's injected into the factory, making the whole thing testable without hitting real networks.

Test Implementations

Different implementations serve different purposes, and they're all just static properties on the same type:

extension LandmarkService {
    static var mock: LandmarkService {
        let store = LandmarkStore()

        return LandmarkService(
            store: store,
            fetchLandmarks: {
                await store.setLoading()
                try await Task.sleep(for: .milliseconds(500))
                await store.setLandmarks(Landmark.sampleData)
            },
            fetchLandmark: { id in
                try await Task.sleep(for: .milliseconds(200))
                return Landmark.sampleData.first { $0.id.uuidString == id }
            },
            fetchLandmarksByCategory: { category in
                try await Task.sleep(for: .milliseconds(300))
                return Landmark.sampleData.filter { $0.category == category }
            }
        )
    }
}

The mock includes simulated network delays. This is important - you want to see how your UI behaves during loading states, not just the final result. Those Task.sleep calls make the mock feel realistic without hitting any network.

For SwiftUI previews, you want something faster:

extension LandmarkService {
    static var preview: LandmarkService {
        let store = LandmarkStore()
        let landmarks = Landmark.sampleData

        // Pre-populate immediately
        Task { @MainActor in
            store.setLandmarks(landmarks)
        }

        return LandmarkService(
            store: store,
            fetchLandmarks: { },  // Already loaded, do nothing
            fetchLandmark: { id in landmarks.first { $0.id.uuidString == id } },
            fetchLandmarksByCategory: { category in landmarks.filter { $0.category == category } }
        )
    }
}

The preview variant pre-populates the store immediately. The fetchLandmarks closure is empty because there's nothing to fetch - the data is already there. Previews render instantly with realistic content.

Finally, there's the unimplemented variant:

extension LandmarkService {
    static var unimplemented: LandmarkService {
        LandmarkService(
            store: LandmarkStore(),
            fetchLandmarks: { fatalError("fetchLandmarks not implemented") },
            fetchLandmark: { _ in fatalError("fetchLandmark not implemented") },
            fetchLandmarksByCategory: { _ in fatalError("fetchLandmarksByCategory not implemented") }
        )
    }
}

Every closure calls fatalError(). Use this as the default in your environment. If a view calls a service method you didn't expect, you'll get an immediate, obvious crash instead of mysterious behavior. It turns silent bugs into loud failures.

SwiftUI Environment Integration

SwiftUI's environment system is perfect for dependency injection. It propagates values down the view hierarchy automatically - inject at the top, access anywhere below. No manual passing of dependencies through initializers, no singletons, no service locators.

The environment works through key-value pairs. You define a property on EnvironmentValues with a default value, and views access it through the @Environment property wrapper:

extension EnvironmentValues {
    @Entry var landmarkService: LandmarkService = .unimplemented
}

Notice the default is .unimplemented. This is intentional. If you forget to inject a service somewhere in your app, you'll get an immediate crash with a clear message rather than mysterious behavior or silent failures. It's the "fail fast, fail loud" principle - problems surface immediately during development rather than lurking until production.

Some people prefer using an optional default (nil) and handling the missing case in views. I find that approach leads to defensive code scattered throughout the UI layer. With .unimplemented as the default, the contract is clear: services must be injected, and if they're not, you'll know right away.

Add a convenience view modifier to make injection ergonomic:

extension View {
    func landmarkService(_ service: LandmarkService) -> some View {
        environment(\.landmarkService, service)
    }
}

At the app level, inject once and you're done:

@main
struct LandmarksApp: App {
    let appServices: Services

    init() {
        appServices = .mock  // or .live(baseURL: ...) for production
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
                .services(appServices)
        }
    }
}

Every view in the hierarchy can now access the service through @Environment(\.landmarkService).

Views Observe the Store Directly

Here's where it all comes together. A view grabs the service from the environment, then observes its store:

struct LandmarkListView: View {
    @Environment(\.landmarkService) private var landmarkService

    private var store: LandmarkStore { landmarkService.store }

    var body: some View {
        Group {
            switch store.loadingState {
            case .idle, .loading:
                ProgressView("Loading landmarks...")
            case .failed(let error):
                ContentUnavailableView(
                    "Unable to Load",
                    systemImage: "exclamationmark.triangle",
                    description: Text(error.localizedDescription)
                )
            case .loaded:
                landmarksList
            }
        }
        .navigationTitle("Landmarks")
    }

    private var landmarksList: some View {
        List {
            if !store.featuredLandmarks.isEmpty {
                Section("Featured") {
                    ForEach(store.featuredLandmarks) { landmark in
                        LandmarkRow(landmark: landmark)
                    }
                }
            }

            Section("All Landmarks") {
                ForEach(store.landmarks) { landmark in
                    LandmarkRow(landmark: landmark)
                }
            }
        }
    }
}

No view model. The view observes store.loadingState and store.landmarks directly. When the service updates the store, SwiftUI automatically re-renders. The @Observable macro handles all the observation machinery.

The view's job becomes simple: read state, render UI. That's it. Loading states, error handling, data transformation - all handled elsewhere. The view just presents what the store contains.

Scaling Up: The Services Container

Real apps have multiple services - landmarks, analytics, authentication, user preferences. Rather than injecting each one separately, group them in a container:

struct Services: Sendable {
    let landmarks: LandmarkService
    let analytics: AnalyticsService
}

extension Services {
    static func live(baseURL: URL) -> Services {
        let client = NetworkClient.live(baseURL: baseURL)
        return Services(
            landmarks: .live(client: client, baseURL: baseURL),
            analytics: .live
        )
    }

    static var mock: Services {
        Services(landmarks: .mock, analytics: .live)
    }

    static var preview: Services {
        Services(landmarks: .preview, analytics: .preview)
    }

    static var unimplemented: Services {
        Services(landmarks: .unimplemented, analytics: .unimplemented)
    }
}

The container has the same static properties as individual services. When you inject the container, a view modifier can automatically inject all the individual services too:

extension View {
    func services(_ services: Services) -> some View {
        environment(\.services, services)
            .environment(\.landmarkService, services.landmarks)
            .environment(\.analyticsService, services.analytics)
    }
}

One line at the app level configures everything. Views can access individual services through @Environment(\.landmarkService) or the whole container through @Environment(\.services) - whatever makes sense for that view.

This scales nicely. Adding a new service means adding a property to the container and updating the factory methods. Views that need the new service just grab it from the environment.

Why This Beats MVVM

Traditional MVVM creates a view model for each view that transforms data and manages state. With this pattern, that work is already done:

The store is the view model. It holds observable state that views react to. No separate class needed.

Domain models handle business logic. Computed properties like displayTitle live on the model itself, not scattered across view models.

Services own the operations. Fetching, updating, and deleting happen in service closures, not view model methods.

Views just observe and render. They pull state from the store, call service methods on user actions, and that's it.

The ceremony of creating view model protocols, implementations, and bindings disappears. You're left with a clean, unidirectional data flow: user action triggers service method, service updates store, view re-renders.

I've seen codebases where every single view has a corresponding view model, even when that view model does nothing but forward data from a service. With this pattern, those pass-through view models simply don't exist. The view talks to the service directly.

Previews Just Work

SwiftUI previews become trivial:

#Preview {
    NavigationStack {
        LandmarkListView()
    }
    .services(.preview)
}

The .preview services have instant data with no delays. Previews render immediately with realistic content. No mock setup, no waiting, no "preview crashed because the service wasn't configured."

This is one of those quality-of-life improvements that compounds over time. When previews are fast and reliable, you use them more. When you use them more, you catch issues earlier. When you catch issues earlier, you ship better code.

Wrapping Up

Closure-based services give you all the benefits of dependency injection without the protocol boilerplate:

Easy to swap implementations. Live, mock, preview, unimplemented - just different instances of the same struct. No protocol hierarchies, no separate types.

Observable stores. Views watch the store directly using @Observable. The store is the view model. No intermediary needed.

Environment integration. SwiftUI's environment system handles injection beautifully. One line at the app level configures everything.

Services container. Group related services for easy management as your app grows. Add new services without touching existing code.

Clean testing. Use .unimplemented as the default to catch unexpected calls. Use .mock for controlled behavior with realistic delays.

The result is less code, clearer data flow, and views that focus purely on presentation. Combined with the domain model pattern from the previous post, you have a complete architecture that scales from simple apps to complex ones - without the ceremony of protocols, view models, or DI frameworks.

The best part? It's all just Swift. No external dependencies, no code generation, no magic. Just structs, closures, and SwiftUI's built-in environment system working together.

In the next post, we'll add tiered caching with memory and disk layers, LRU eviction, and flexible fetch policies that give your app offline support for free.