iOS – Dependency Injection Without a Framework

August 25, 20268 min readUpdated 9/18/2026

Dependency injection sounds like something that needs a framework. It does not. It is one question — who decides which concrete types this app runs with? — and the answer that works is a single initialiser you can read.

This lesson is that initialiser, the case against the singleton it replaces, and how the graph reaches a view without being threaded through every layer in between.

Why not a singleton

Every type in the demo app could have been a static let shared. Three reasons none of them is.

Tests would share the app's wiring. A singleton HTTP client is a client every test talks through, and "did this test touch the network?" stops being answerable. Worse, tests start affecting each other through shared state, which produces failures that move when you reorder them.

The graph would be invisible. Dependencies expressed as global access are dependencies nobody can see. There is no file that tells you what this app is made of, and no way to know what breaks when you change something.

There is nowhere to swap a fake. Not just in tests — in SwiftUI previews, in a demo mode, in a UI test that needs a deterministic backend.

The composition root

@MainActor
@Observable
final class AppEnvironment {
    // Configuration
    let configuration: APIConfiguration

    // Data layer
    let authRepository: AuthRepository
    let catalogRepository: CatalogRepository
    let cartRepository: CartRepository
    let orderRepository: OrderRepository
    let profileRepository: ProfileRepository

    // Application state
    let auth: AuthStore
    let menu: MenuStore
    let cart: CartStore
    let toasts: ToastCenter

    // Payment
    let paymentGateway: PaymentGateway

Every property is a protocol except the stores. That is the point: the container knows what the app needs, not how any of it is implemented.

The production graph is one function:

    static func live() -> AppEnvironment {
        let configuration = APIConfiguration.fromBundle()
        let tokenStore = TokenStore(secureStore: KeychainSecureStore())
        let client = URLSessionHTTPClient(configuration: configuration, tokenProvider: tokenStore)

        return AppEnvironment(
            configuration: configuration,
            tokenStore: tokenStore,
            authRepository: RemoteAuthRepository(client: client),
            catalogRepository: RemoteCatalogRepository(client: client),
            cartRepository: RemoteCartRepository(client: client),
            orderRepository: RemoteOrderRepository(client: client),
            profileRepository: RemoteProfileRepository(client: client),
            cartIdentifierStore: CartIdentifierStore(store: UserDefaultsKeyValueStore()),
            paymentGateway: StripePaymentGateway(
                publishableKey: configuration.stripePublishableKey
            )
        )
    }

Read it top to bottom and you know the whole shape of the app. The order is the dependency graph made literal — the token store first because the client needs it to attach a bearer token, repositories next because they need the client. If it were wrong, this would not compile.

The ordering rule the compiler enforces

One dependency in the demo app is genuinely load-bearing:

        self.cart = CartStore(
            repository: cartRepository,
            identifierStore: cartIdentifierStore,
            menuStore: menu
        )

Rehydrating a saved cart needs the catalogue to re-price it, because a stored line holds only identifiers. Hydrate before the menu loads and you get a basket full of zero-priced items.

What makes this worth showing is the comparison. The React Native version of this app expresses the same constraint as the order of two provider components, with a comment warning not to swap them — and getting it wrong crashes at runtime, on the first launch that happens to have a saved cart. Here it is a constructor parameter. There is no ordering to get wrong, because a store that does not exist yet cannot be passed.

That is the general argument for constructor injection over ambient access: it turns a rule you have to remember into one the compiler already checked.

Handing it to the views

The root builds it once and puts it in the environment:

    @State private var environment = AppEnvironment.live()

@State rather than let, because SwiftUI re-creates the App struct on any invalidation. A let would rebuild the whole graph — new stores, an empty cart, a lost session — every time.

Then the stores go in individually alongside it, so a view that only needs the cart can ask for the cart rather than reaching through a container:

                .environment(environment)
                .environment(environment.auth)
                .environment(environment.menu)
                .environment(environment.cart)
                .environment(environment.toasts)

Both are injected on purpose. A screen reads a store directly; a screen that needs to construct something — a view model with three dependencies — reads the container.

Building a view model from the container

        .task {
            guard model == nil else { return }

            let model = CheckoutViewModel(
                orderRepository: environment.orderRepository,
                profileRepository: environment.profileRepository,
                paymentGateway: environment.paymentGateway
            )
            model.prefill(from: auth.user)
            self.model = model

            await model.loadSavedAddresses(isAuthenticated: auth.isAuthenticated)
        }

Built in .task, not in init, and that is not a style choice: @Environment is not readable from an initialiser, because the property wrappers are not populated until the view is installed in the hierarchy. Trying is a runtime crash, not a compile error.

The guard keeps it to once per appearance rather than once per redraw. Without it, re-entering the screen would discard whatever the customer had typed.

The doubles the container swaps in

An injected protocol is only useful if there is something else to inject. The demo app ships two kinds of double, and the distinction is worth drawing.

Preview doubles return realistic data immediately:

struct PreviewCatalogRepository: CatalogRepository {
    var products: [Product] = SampleData.products
    var toppings: [Topping] = SampleData.toppings
    var crusts: [Crust] = SampleData.crusts
    /// Set to simulate the failure branch — the state previews most often forget to look at.
    var error: Error?

    func products() async throws -> [Product] { try result(products) }

The sample data behind them is deliberately realistic rather than tidy: a pizza with three sizes, a crust with a surcharge, a two-line address, a card with no expiry. Preview data that is too neat hides exactly the layout problems previews exist to catch — the two-line product name, the long topping list, the missing field.

Test spies hold the same canned responses and additionally record what they were asked for. OrderRepositorySpy declares a settable Result<OrderCreateResponse, Error> so a test can choose success or failure, and beside it:

    private(set) var createCallCount = 0
    private(set) var lastCreateRequest: OrderCreateRequest?
    private(set) var lastCreateWasAuthenticated: Bool?

Asserting on the call as well as the result is what catches the bugs a return value cannot: a screen that fetches twice, a write that never happens, a request sent without authentication. The whole preview file is wrapped in #if DEBUG, so none of it — including the sample email addresses — is compiled into a release build.

What this makes possible: previews

    static func preview(
        catalogue: CatalogRepository = PreviewCatalogRepository(),
        orders: OrderRepository = PreviewOrderRepository(),
        profile: ProfileRepository = PreviewProfileRepository()
    ) -> AppEnvironment {

One function hands the entire app a stubbed graph. Previews run in a host process with no backend, no Keychain entitlement and no view controller to present a payment sheet from — so everything that would reach outside the process is replaced, and a preview renders instantly and deterministically instead of showing a spinner that never resolves.

The parameters are what make it useful rather than merely functional. Each preview can override one repository, which is how the failure state gets a preview of its own:

#Preview("Failed") {
    let environment = AppEnvironment.preview(
        catalogue: PreviewCatalogRepository(error: APIError.network(message: "The backend is not running."))
    )

The error branch is the one nobody looks at, because reproducing it means turning the backend off. Here it is one line, and it is visible beside the happy path in the canvas.

What this makes possible: tests

Tests do not use the container at all — they construct exactly what they need:

    @MainActor
    private func makeStore(
        repository: AuthRepositorySpy = AuthRepositorySpy(),
        secureStore: SecureStore = InMemorySecureStore()
    ) -> (AuthStore, AuthRepositorySpy, TokenStore) {
        let tokenStore = TokenStore(secureStore: secureStore)
        return (AuthStore(repository: repository, tokenStore: tokenStore), repository, tokenStore)
    }

No network, no Keychain, no shared state between tests. That is only possible because AuthStore takes its dependencies rather than reaching for them.

The protocol that breaks a cycle

One dependency in this graph is circular if you draw it naively. The HTTP client needs the auth token. The token belongs to the session, which is restored by calling the HTTP client. Depending on a concrete type in both directions would not compile.

protocol AuthTokenProviding: Sendable {
    func currentToken() async -> String?
}

One method. The client now knows only "something can give me a token", which is also exactly what a test needs to stub. This is the general shape of the fix: when two types need each other, the one with the smaller requirement gets a protocol describing just that requirement — and it usually turns out to be a better boundary than the concrete dependency was.

What does not go in the container

Not everything is a dependency. Pure functions — Money.format, CartPricing.totals, CartReducer.reduce — are called directly as statics, and injecting them would be pure ceremony: they perform no I/O, they have no state, and a test can call them as they are.

The test is whether substituting it in a test would ever be useful. For a repository, yes. For a rounding function, no. Applying dependency injection to everything is how a codebase acquires forty protocols with one implementation each, which is the failure mode that gives the pattern its reputation.

The rules, in four lines

  • Depend on protocols, not concrete types, wherever the concrete type does I/O.
  • Inject through the initialiser. A type should never construct something it talks to over a network.
  • One composition root, and it is the only place concrete types are named.
  • No singletons for anything stateful or anything that performs I/O.

Reading the environment is not static

One sharp edge worth knowing before you rely on this. @Environment(MenuStore.self) on a type nobody injected is a crash, not a compile error. SwiftUI has no way to prove at build time that an ancestor supplied it.

In practice it fails the first time you render that screen, which is soon enough that it is rarely a real problem — but it is the reason previews have to inject the same things the app does, and the reason a preview that suddenly crashes is usually missing one .environment line rather than broken in any interesting way.

When a framework would help

Manual injection scales further than people expect, and it stops being pleasant somewhere around "the initialiser has twenty parameters and half of them are only forwarded". At that point the options are to split the container by feature, or reach for something like Factory or swift-dependencies.

Both are reasonable. Neither removes the need to understand what is above, because a resolution failure in a container is a runtime crash where a missing constructor parameter is a compile error — and the framework's job is to hide exactly the wiring you would otherwise be reading in one place.

Next

The container's first line constructs a Keychain-backed store, which has gone unexplained for two lessons. The next one is persistence: where each thing belongs, and why the split is a security decision rather than a convenience one.