Dependency Injection vs. Dependency Inversion in Swift
Trace object creation, type dependencies, protocol ownership, and test doubles through a complete Swift example.
Dependency injection and dependency inversion often appear in the same example, which makes them easy to conflate. They describe different decisions. Injection answers: who creates and supplies this object's collaborator? Inversion answers: does the higher-level code name a concrete implementation or a contract it needs? We can change one decision without changing the other.
Updated September 2026 with current Swift examples.
The dependency inversion principle predates Swift. Robert C. Martin introduced it in a C++ Report article from the 1990s; the examples here express the same dependency direction with current Swift protocols.
The examples use a writer that sends note text to a store. ConsoleStore prints the text so every step can be run without files or networking.
1. A hard-wired collaborator
The first writer creates the store inside write(_:):
struct ConsoleStore {
func save(_ text: String) {
print(text)
}
}
struct HardwiredNoteWriter {
func write(_ text: String) {
ConsoleStore().save(text)
}
}
HardwiredNoteWriter().write("Hello")
The caller can choose the text, but it cannot supply another store. Both construction and use of the dependency are inside HardwiredNoteWriter. The writer also names ConsoleStore in its source code.
2. Injection without inversion
Move construction to the caller and pass the store through the initializer:
struct ConcreteNoteWriter {
let store: ConsoleStore
func write(_ text: String) {
store.save(text)
}
}
let concreteWriter = ConcreteNoteWriter(store: ConsoleStore())
concreteWriter.write("Hello")
This is constructor injection. Swift gives the struct a memberwise initializer, and the caller supplies its store argument. The writer no longer decides when to construct a store. However, the property type is still ConsoleStore. Replacing it with an unrelated storage type would require changing the writer's declaration.
Injection can also be done for one method call rather than stored for the lifetime of an object:
func write(_ text: String, using store: ConsoleStore) {
store.save(text)
}
write("Hello", using: ConsoleStore())
That is method injection. Both injected examples still depend on the concrete type. Injection does not require a protocol or a dependency-injection framework.
3. Invert the declared dependency
Robert C. Martin's Dependency Inversion Principle describes higher-level policy and lower-level details depending on abstractions. A Swift protocol can state the one operation the writer needs:
protocol NoteStore {
func save(_ text: String)
}
extension ConsoleStore: NoteStore {}
struct NoteWriter {
let store: any NoteStore
func write(_ text: String) {
store.save(text)
}
}
let writer = NoteWriter(store: ConsoleStore())
writer.write("Hello")
NoteWriter now mentions NoteStore instead of ConsoleStore. The extension declares that the existing console implementation satisfies the protocol. The caller still chooses the concrete value, so this example uses both inversion and injection.
The any NoteStore property is a boxed protocol value: it can hold an instance of any conforming type, while NoteWriter can rely only on the operations declared by NoteStore. The protocol says what the writer needs; the construction line says which implementation this run uses. Swift's protocol documentation describes protocol requirements and boxed protocol values.
4. Replace a collaborator in a test
Because the writer asks for the protocol, a test can supply a recorder. It records the argument instead of printing it:
import Testing
final class RecordingStore: NoteStore {
private(set) var saved: [String] = []
func save(_ text: String) {
saved.append(text)
}
}
@Test func writerPassesTextToStore() {
let store = RecordingStore()
let writer = NoteWriter(store: store)
writer.write("Hello")
#expect(store.saved == ["Hello"])
}
This test checks the writer's observable interaction with its store. It does not claim that console output or disk storage works. RecordingStore is created inside the test, so this test does not need shared mutable state.
5. The module boundary is part of inversion
A protocol name in one file does not, by itself, describe the direction of dependencies between modules. Consider this arrangement:
NotesDomain: NoteStore protocol + NoteWriter
ConsoleAdapter: ConsoleStore, imports NotesDomain, conforms to NoteStore
App: imports both modules and constructs NoteWriter(store: ConsoleStore())
Here the higher-level NotesDomain module can compile without importing ConsoleAdapter. The adapter imports the module that owns the contract. If the protocol were declared only in ConsoleAdapter, the domain module would have to import that lower-level module just to name the type; wrapping a concrete class in a protocol would not reverse that source dependency.
This placement is an architectural choice. Swift enforces import and access rules, but it does not decide which module is “higher-level” for your application. The original principle explains why dependency direction matters; a project's module layout makes that direction visible.
6. A generic alternative to any
The writer can also express the same protocol requirement as a generic constraint:
struct GenericNoteWriter<Store: NoteStore> {
let store: Store
func write(_ text: String) {
store.save(text)
}
}
GenericNoteWriter(store: ConsoleStore()).write("Hello")
GenericNoteWriter<ConsoleStore> has a specific store type in its type itself. NoteWriter with any NoteStore can instead hold any conforming store behind the same property type. Both depend on NoteStore's requirements; choosing between a generic and a boxed protocol value is a separate Swift type-design decision.
To identify the concepts in existing code, look at two different lines: the initializer or method call that supplies the collaborator, and the property or parameter declaration that names its type. The first reveals injection. The second, together with module imports, reveals whether the higher-level code depends on an abstraction.