Dependency injection in Swift without a framework
All Articles
· Swift iOS Architecture Testing

Dependency injection in Swift without a framework

You do not need a DI framework just to say you use DI. Swift already gives most apps strong defaults, and libraries like swift-dependencies can still be a great fit when they solve real problems.


Every experienced iOS developer I know has a strong opinion on dependency injection. Some teams standardize on a library (Needle, Swinject, Factory, swift-dependencies), and others prefer to stay close to the language.

My view is not “never use a DI library.” It is simpler than that: do not introduce one just for the sake of having one.

In 2025, Swift’s type system, concurrency model, and language ergonomics are strong enough that many apps can get excellent DI with no third-party framework at all. But many apps also benefit from a DI library once the dependency graph, override story, or module boundaries become painful enough to justify one.

What Problem Are We Actually Solving?

Dependency injection is mostly about two things:

  1. Testability: swap real implementations for test doubles without modifying the code under test.
  2. Flexibility: configure behavior at the composition root, not in the middle of your logic.

When you hear “DI container” or “service locator,” you’re hearing about implementation strategies. They are not the core problem by themselves.

Protocol-Based Injection: The Foundation

The pattern is still simple: model capabilities as protocols and inject those abstractions.

protocol NetworkClient: Sendable {
    func fetch<T: Decodable & Sendable>(
        _ request: URLRequest,
        as type: T.Type
    ) async throws -> T
}

struct LiveNetworkClient: NetworkClient {
    let session: URLSession

    init(session: URLSession = .shared) {
        self.session = session
    }

    func fetch<T: Decodable & Sendable>(
        _ request: URLRequest,
        as type: T.Type
    ) async throws -> T {
        let (data, response) = try await session.data(for: request)
        guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
            throw URLError(.badServerResponse)
        }
        return try JSONDecoder().decode(type, from: data)
    }
}

struct TestNetworkClient: NetworkClient {
    var handler: @Sendable (URLRequest) async throws -> Data

    func fetch<T: Decodable & Sendable>(
        _ request: URLRequest,
        as type: T.Type
    ) async throws -> T {
        let data = try await handler(request)
        return try JSONDecoder().decode(type, from: data)
    }
}

Your domain code depends on NetworkClient. Tests inject TestNetworkClient; production injects LiveNetworkClient. No container required, and no force-casting needed.

Constructor Injection: The Right Default

Inject dependencies through initializers by default.

final class UserService {
    private let network: any NetworkClient
    private let cache: any CacheStore

    init(network: any NetworkClient, cache: any CacheStore) {
        self.network = network
        self.cache = cache
    }
}

Initializer injection keeps dependencies explicit and local. If a type needs ten constructor arguments, that’s usually a design smell. The pain is useful feedback: split responsibilities instead of hiding complexity behind container magic.

The Environment Pattern for Cross-Cutting Concerns

Some dependencies are genuinely cross-cutting: analytics, logging, feature flags, tracing. Passing each one through every layer can become noisy. In those cases, a shared environment can be cleaner than constructor-drilling if ownership stays explicit.

struct AppEnvironment {
    var network: any NetworkClient
    var cache: any CacheStore
    var analytics: any Analytics
    var featureFlags: any FeatureFlagging
    var logger: any Logger

    static var live: AppEnvironment {
        AppEnvironment(
            network: LiveNetworkClient(),
            cache: DiskCache(),
            analytics: FirebaseAnalytics(),
            featureFlags: RemoteConfig(),
            logger: OSLogger()
        )
    }

    static var test: AppEnvironment {
        AppEnvironment(
            network: TestNetworkClient(handler: { _ in throw URLError(.unsupportedURL) }),
            cache: InMemoryCache(),
            analytics: NoOpAnalytics(),
            featureFlags: StaticFeatureFlags(),
            logger: PrintLogger()
        )
    }
}

Create one environment at the composition root (App entry point, app coordinator, or feature root), then pass dependencies intentionally. In SwiftUI specifically, use EnvironmentValues for view concerns and keep service wiring in your composition root.

This is conceptually close to TCA’s dependency system: explicit keys, scoped overrides, and test-friendly defaults. Different ergonomics, same architectural principle.

Composition Root: Where the Graph Gets Built

One place assembles the dependency graph. Everywhere else consumes already-wired dependencies. That’s your composition root.

@main
struct MyApp: App {
    let environment: AppEnvironment
    let userService: any UserServiceProtocol

    init() {
        let env = AppEnvironment.live
        self.environment = env
        self.userService = UserService(
            network: env.network,
            cache: env.cache
        )
    }

    var body: some Scene {
        WindowGroup {
            RootView(
                environment: environment,
                userService: userService
            )
        }
    }
}

No runtime registration. No reflection. No hidden lifecycle decisions. Just explicit wiring you can read top-to-bottom.

Lifetime and Concurrency Rules (Swift 6+)

In modern Swift, DI choices also affect correctness under strict concurrency:

  • Prefer immutable dependencies (let) after initialization.
  • Make dependency protocols Sendable when they cross actor/task boundaries.
  • Ensure stored dependencies are actually concurrency-safe, not just protocol-marked Sendable.
  • Isolate UI-facing services to @MainActor where appropriate.
  • Avoid shared mutable singleton state unless it is actor-isolated.

That last point matters in small examples too. A type can conform to a Sendable protocol and still hide unsafe shared state internally. Things like JSONDecoder, DateFormatter, and mutable caches are better created per use, wrapped in an actor, or kept behind the right isolation boundary.

Most “DI bugs” in 2025 are not injection bugs; they are lifetime and isolation bugs.

Testing Without Mocks Everywhere

A pattern that paid off the most for me: use real implementations in tests when practical.

A test double that returns pre-decoded model objects often skips the exact code that breaks in production (decoding, status-code checks, date formatting, retry behavior).

A stronger test uses real code paths with controlled boundaries: URLSession + URLProtocol stubs, real JSON decoding, and a real cache implementation backed by temporary storage.

Default to real implementations unless cost, nondeterminism, or observability make that impractical.

Reserve mocks/fakes for:

  • Genuinely expensive or unavailable infrastructure (third-party APIs, APNs, flaky provider sandboxes in CI)
  • Non-deterministic behavior you need to control (time, randomness, UUID generation)
  • Error paths that are expensive or unsafe to trigger with real systems

A simple test strategy ladder:

  • Unit: real domain logic, minimal fakes at the boundary
  • Integration: real adapters with controlled I/O (URLProtocol, temp file system, test database)
  • Contract: verify request/response shape against external services so provider changes fail fast

The goal is confidence per minute, not purity. Real paths catch more bugs, and fewer hand-rolled mocks means less test maintenance after refactors.

When a DI Library Might Make Sense

I’m not dogmatic about this. A DI library can absolutely earn its place when:

  • Your app has dozens of modules and manual wiring is becoming a maintenance bottleneck
  • You need dynamic runtime scopes (tenant/session/workspace-level graphs)
  • Your team benefits from standardized dependency access, override mechanics, or compile-time graph validation
  • Tests need a cleaner way to override dependencies across feature boundaries without threading everything manually

Even then, prefer the narrowest tool that solves your actual problem. swift-dependencies is a good example: it is not only useful in TCA, and it can be a strong fit even in non-TCA codebases when you want structured dependency keys, scoped overrides, and test-friendly ergonomics without adopting a broader container model.

My default recommendation is still the same: start with protocols, initializer injection, and a clear composition root. Add a library when it removes real friction, not as a signal that the architecture is “serious.”

That framing matters. The goal is not to avoid libraries on principle; it is to keep dependency management proportionate to the problem in front of you. Boring manual wiring is often easier to debug at 2 a.m., but a well-chosen library can also be the boring, pragmatic choice once the system is large enough.