Est.

Dependency Injection in Swift Without Third-Party Containers

Swift's built-in features handle dependency injection without external frameworks.

Contributing Editor · · 13 min read
Cover illustration for “Dependency Injection in Swift Without Third-Party Containers”
Swift Architecture · September 16, 2026 · 13 min read · 2,883 words

A ViewModel that builds its own dependencies is easy to write and hard to live with. The moment a ProfileViewModel calls let service = NetworkService() inside its own body, that ViewModel has decided, unilaterally, what network stack the entire app is going to use. No test can override it. No later engineer can swap it. That single line of code has quietly foreclosed on every future decision about how that class talks to the outside world.

Dependency injection is the fix, and it is a plainer idea than the term suggests. An object that needs something gets handed that something from outside, rather than reaching out and constructing it itself. That is the whole concept. Robert Martin's Dependency Inversion Principle, the "D" in SOLID, gives it a formal shape: high-level modules should depend on abstractions, not on concrete implementations. DI is just the mechanical, everyday version of that rule, applied at the level of init methods and protocols instead of architecture diagrams.

Scale is why this matters more than it looks like it should. A handful of tightly coupled classes in a small app is a minor inconvenience, something a solo developer can hold in their head. Dozens of them turns into a coordination problem, where changing one service ripples sideways into screens nobody thought were connected. Across a large team, it becomes an architectural liability, the kind that appears in comments like "nobody wants to touch that module anymore."" None of this requires a third-party framework to solve. Swift's own language features, protocols, initializers, property wrappers, macros, and the SwiftUI Environment, already contain everything needed to build a dependency graph that's clean and testable. That's the subject of what follows.

Why initializer injection earns its status as the default choice

The mechanics are almost too simple to call a pattern: a dependency gets passed in as a parameter to init, then stored as a private, immutable property. A ProfileViewModel(service: ProfileServiceProtocol) takes a LiveProfileService in production and a MockProfileService in a test target, and the ViewModel itself never knows the difference.

That not-knowing is the entire point. The ViewModel depends on the shape of ProfileServiceProtocol, its methods and return types, not on any particular class that fulfills it. Swap the live implementation for a mock, a stub, or a second production variant, and the consumer's code doesn't change at all.

Initializer injection earns its status as the default for a mechanical reason: it makes partial initialization impossible. Every dependency has to exist before the object does. There's no window in the object's lifecycle where it's sitting around with a nil property waiting to be assigned, which is exactly the kind of window that produces runtime crashes at 2 a.m. Default parameter values keep this clean at the call site: init(service: FeedServicing = DefaultFeedService()) lets production code call FeedViewModel() with no ceremony, while a test can still override the parameter directly.

The pattern strains at scale. A ViewModel with eight or nine dependencies produces an initializer that's unreadable and painful to call from anywhere, and that strain is what motivates the container-based and other patterns covered later. Before getting there, three mistakes occur constantly in real codebases and deserve to be named directly. Injecting a concrete type instead of a protocol defeats the purpose entirely, since the consumer is still coupled to one implementation. Mixing injection styles inconsistently makes the codebase harder to reason about than having no DI discipline at all, with initializer injection used in one module and property injection in another, and no stated reason for the difference. And dependencies quietly instantiated inside extensions, out of view of the main type definition, hide the coupling that initializer injection was supposed to expose.

What property injection and method injection solve and where they belong

Property injection sets the dependency after the object already exists, through a mutable property, something like var analyticsService: AnalyticsService? sitting on a DashboardViewModel. It's flexible, and it's also inherently risky in a specific way: the property is optional. There's a window where code can call methods on that object before the dependency has been set. That's a bug waiting for the wrong sequence of events.

The pattern still has a legitimate home. Delegate relationships are the classic case, where the delegate can't be known until after both objects exist. Storyboard-instantiated view controllers are another, since the framework that builds them constructs them before any custom init runs, leaving property injection as close to the only option available.

Method injection is narrower still: the dependency gets passed as a parameter to one method, rather than stored on the object at all. It fits one-off operations where the dependency matters only for the duration of a single call, an export function that needs a formatter, say, without the object needing to hold a reference to that formatter forever. This keeps the type's surface area small. A class doesn't need a stored property for something it uses once.

The general rule holds across all three patterns: default to initializer injection, and reach for property or method injection only when the object's actual lifecycle demands it. Treating them as interchangeable stylistic choices is how a codebase ends up with three different DI conventions and no clear reason for any of them.

How SwiftUI's Environment acts as a built-in dependency graph

SwiftUI ships its own dependency graph, and most developers use it every day without naming it that. EnvironmentValues behaves like a typed dictionary that flows down the view hierarchy. Any view reads from it with @Environment, and any ancestor view can modify what its descendants see with .environment(_:_:).

Two property wrappers get confused constantly, and they do different jobs. @Environment reads from SwiftUI's own built-in keys, things like colorScheme or dismiss, alongside any custom keys a developer defines. @EnvironmentObject is the older mechanism for injecting arbitrary ObservableObject instances into the hierarchy, and it predates the newer @Observable macro discussed next.

Defining a custom environment key, before Xcode 16, takes three steps: conform a type to EnvironmentKey, supply a defaultValue, and extend EnvironmentValues with a computed property that reads and writes through that key. The key type itself can stay private, since consumers never touch it directly, they go through the key path. It's boilerplate-heavy, but every step is visible, which makes it easy to audit what's flowing through the environment and where its default comes from.

Xcode 16 introduced the @Entry macro, which collapses those three steps into a single declaration with identical semantics and a fraction of the ceremony. The practical scope for either approach is the same: app-wide services that a lot of views need without threading them through every initializer by hand, a networking stack, an analytics client, a feature-flag service. That said, EnvironmentValues only exist once a view has loaded into the hierarchy. They're not readable from outside a view's body, which matters the moment a service needs to be available to a plain Swift object that isn't a view at all, a coordinator, a background task, a data layer. That gap is what the manual DependencyProvider pattern, covered later, exists to close.

The @Observable macro changes how reference types move through the environment

The @Observable macro, introduced alongside iOS 17, replaces the older ObservableObject plus @Published combination, and in most cases it removes the need for @StateObject, @ObservedObject, and @EnvironmentObject altogether. Injection changes shape along with it: @Environment(YourObservableType.self) replaces @EnvironmentObject, and the type itself becomes the key instead of a separate wrapper managing it.

The efficiency gain is concrete. Under the old ObservableObject model, any change to any @Published property re-renders every view subscribed to that object, whether or not the view actually reads the changed property. @Observable tracks access at the property level, so a view that only reads user.name doesn't re-render when user.lastLoginDate changes elsewhere in the same object.

One migration detail deserves more attention than Apple's own documentation gives it. Under ObservableObject, a view that owns an instance uses @StateObject to do it. Under @Observable, ownership shifts to plain @State, and continuing to reach for @StateObject here is a meaningful behavioral mistake, not just a stylistic choice. It's a silent behavioral difference that compiles cleanly and hides in plain sight. It's a silent behavioral difference that can persist unnoticed and surface as an ownership bug that's difficult to trace back to its source.

Swift 6's stricter concurrency model adds one more layer to think through. Injecting a reference type through the environment means thinking about what thread it's being touched from. UI-bound observable objects belong on @MainActor, and any inference or computation work that object triggers belongs in an async task, not inline on the main thread. @Observable has a floor: iOS 17 and macOS 14. Apps still supporting earlier OS versions either maintain two tracks of ViewModel code or stay on ObservableObject until that floor is no longer a constraint.

Building a manual DependencyProvider when the view hierarchy can't carry every dependency

Not everything that needs a dependency lives inside a SwiftUI view. Coordinators, data layers, background services, none of them sit in the view hierarchy, so none of them can read from EnvironmentValues. They still need a consistent, testable way to receive what they depend on. A manual DependencyProvider comes in to fill that role.

The idea is a container with a small, generic resolution API: something like @MainActor public protocol DependencyProvider: AnyObject, Sendable { func resolve<T>() -> T }. Each dependency's own protocol type serves as its key. There's no string-based registration to typo, and no force-unwrapped optional waiting to crash at runtime.

The design goal is compile-time safety: if the app builds, every dependency is wired correctly, full stop. A missing registration should be a build failure that appears immediately, well before a runtime crash discovered in a crash report three weeks after shipping. Storing shared instances on AppDelegate looks like a shortcut to the same goal, but it just relocates the singletons rather than eliminating them: it ties dependencies to the app's lifecycle object, forces main-thread assumptions onto services that may not need them, and is invisible to app extensions. A DependencyProvider lives independently of any lifecycle object. It can be shared across an extension and a main app target without modification.

This pattern is not free, and it's not always warranted. It earns its place in a highly modular app, a large dependency graph, or a team big enough that undocumented conventions become a liability. In a small to mid-sized app, passing dependencies directly at the call site is often more readable, and wrapping that in a container adds a layer of indirection with no real payoff.

Antoine van der Lee's @Injected property wrapper pattern is a practical refinement: it wraps the resolution call so consumers write @Injected(\.networkProvider) var networkProvider, which reads almost identically to SwiftUI's own @Environment syntax. That familiarity is not an accident. It lowers the cognitive cost of the pattern for anyone already fluent in SwiftUI's conventions.

Previews and tests as the proof that the architecture is working

None of this is worth doing for its own sake. The payoff appears in the first unit test that swaps a real network call for a mock without touching the ViewModel's code at all. Because every dependency depends on a protocol, a test can hand ProfileViewModel a MockProfileService instead of the live one, so there is no network access, no disk I/O, no flaky side effects tied to server response times.

Xcode Previews get the same benefit, and they deserve to be treated as a first-class testing surface rather than a convenience for laying out UI. Custom environment values and @Observable types can be overridden inside a #Preview block exactly the way they'd be overridden in a test target. Injecting MockFeedService() through .environment(\.feedService, MockFeedService()) produces a preview that renders instantly, with no real API call sitting between a designer and the screen they're trying to look at.

The DependencyProvider pattern pays off here in a specific, measurable way: an unregistered dependency fails the build rather than crashing in production weeks later. That's a meaningfully different failure mode, one that gets caught by the person who introduced it, at the moment they introduced it.

One broader signal carries forward past any single pattern. If a component is awkward to instantiate inside a preview or a test, something about its dependency graph is structurally off, usually a hidden dependency, a concrete type where a protocol should be, or an initializer doing too much. Dependency injection just makes that mistake appear immediately instead of leaving it to appear later, in production, on someone else's phone. It just makes the feedback immediate instead of leaving it to appear later, in production, on someone else's phone.

Applying these patterns to injectable AI service clients

An LLMClient, or a FoundationModelSession, is a dependency like any other. It has a protocol shape, a production implementation, and a mock that tests can substitute in. There's no reason for AI infrastructure to be treated as some kind of special exception to everything above; it's a service boundary, same as networking or persistence.

Apple's on-device AI stack, detailed in an Apple Newsroom announcement in June 2026, gives this pattern real weight rather than hypothetical relevance. The Foundation Models framework exposes on-device inference running on Apple silicon to third-party developers directly. Core AI is a separate framework built for running custom, full-scale LLMs locally, tuned for unified memory and the Neural Engine rather than a general-purpose processing path. App Intents exposes an app's own capabilities up to that AI layer, so the model can act on behalf of a user inside an app it doesn't otherwise control. And Private Cloud Compute handles the cases too heavy for on-device inference, with a separately targetable, higher-capability tier introduced at WWDC 2026 for tasks that need more than the on-device model can give.

The @Generable macro fits neatly into this same DI logic. It lets a developer define a structured output type, a WorkoutPlan struct, say, that the on-device model populates directly, so SwiftUI renders a typed object instead of parsing unstructured text back out of a model's response. That's a meaningful shift: the model becomes a source of typed data that SwiftUI can render directly.

Wrapping that AI client behind a protocol pays off in three concrete ways. Tests swap in a MockLLMClient and skip slow, non-deterministic model calls entirely, which matters enormously for a CI pipeline that needs to run in minutes, not the variable time a real inference call takes. Preview injection lets a designer iterate on AI-driven UI without triggering real inference on every canvas refresh. And the abstraction itself insulates the rest of the codebase from Apple changing the underlying model or its behavior in a future OS release, since the protocol boundary is what the rest of the app actually depends on.

Swift 6's concurrency rules apply here with extra weight, because inference is heavier compute than a typical service call. Inference work belongs in an async task, and UI updates driven by that inference belong on @MainActor, the same discipline used for any other injectable service, just with a noticeably larger compute cost sitting behind the protocol. AI agency on mobile is a genuinely new specialty, not an incremental feature, and the engineers positioned to integrate it fastest are the ones who already have a clean dependency graph in place before the AI client shows up as one more thing to inject.

Choosing the right pattern for the situation you're in

None of these patterns are mutually exclusive, and picking between them is really a question of scope. A single object with a narrow, well-defined purpose calls for initializer injection directly: no wrapper, no container, no ceremony. App-wide services that need to reach a lot of SwiftUI views call for a custom EnvironmentValues key, built with @Entry on Xcode 16 and later, or @Observable paired with @Environment on iOS 17 and later. Non-view types sitting inside a larger, more modular graph call for a DependencyProvider protocol, ideally wrapped in something like the @Injected pattern to keep call sites readable.

A third-party container earns its place in a narrower set of circumstances: a highly modular monorepo, a very large team, automatic resolution across dozens of modules where manual wiring becomes its own maintenance burden. That's a real trade-off, weighed with real costs. It brings a learning curve, an external dependency the team doesn't control, and exposure to that dependency being changed, deprecated, or abandoned on someone else's timeline.

Adding structure before a codebase needs it is its own anti-pattern. A dependency graph should earn the complexity that's added to manage it; complexity imported ahead of that need just becomes overhead nobody asked for. Consistency matters as much as any individual pattern choice: mixing injection styles across a codebase with no documented rationale is one of the clearest signs of a team that never actually agreed on an approach, and it costs new engineers real time every time they have to guess which convention applies where.

Swift's own surface area, protocols, property wrappers, macros, the Environment, and a genuinely stricter concurrency model, has reached a point where most iOS apps can manage their entire dependency graph without importing anything external. That now includes apps built around on-device AI. The tools were always there. What's changed is that there's no longer a good excuse not to use them deliberately.

Sources

  1. Dependency Injection in Swift using latest Swift features
  2. donnywals.com
  3. hackingwithswift.com
  4. Different flavors of dependency injection in Swift | Swift by Sundell
  5. What is dependency injection in Swift? &ndash; Donny Wals
  6. Managing Dependencies in the Age of SwiftUI: Part I of Dependency Injection for Modern Swift Applications
  7. sarunw.com
  8. donnywals.com

More in Swift Architecture