Protocol-Based Dependency Injection

Refactoring Singletons for Testability introduced the pattern. Now let's dive deeper into protocol-based dependency injection.
Follow along with the code: iOS-Practice on GitHub
The Core Idea
Instead of depending on concrete types, depend on protocols that describe capabilities:
// ❌ Depends on concrete type
class OrderService {
let paymentProcessor = StripePaymentProcessor()
}
// ✅ Depends on protocol
class OrderService {
let paymentProcessor: PaymentProcessing
init(paymentProcessor: PaymentProcessing) {
self.paymentProcessor = paymentProcessor
}
}
The second version can work with Stripe, Square, a mock, or anything that conforms to PaymentProcessing.
Designing Good Protocols
Keep Them Focused
// ❌ Too broad - kitchen sink protocol
protocol UserService {
func login(email: String, password: String) async throws -> User
func logout()
func fetchProfile() async throws -> Profile
func updateProfile(_ profile: Profile) async throws
func deleteAccount() async throws
func fetchNotifications() async -> [Notification]
}
// ✅ Focused protocols
protocol Authenticating {
func login(email: String, password: String) async throws -> User
func logout()
}
protocol ProfileProviding {
func fetchProfile() async throws -> Profile
func updateProfile(_ profile: Profile) async throws
}
Small protocols are easier to mock and compose.
Name by Capability
Protocols describe what something can do, not what it is:
// ❌ Named like a class
protocol UserManager { }
protocol NetworkService { }
// ✅ Named by capability
protocol UserFetching { }
protocol RequestSending { }
protocol DataPersisting { }
The -ing suffix signals "this is a capability."
Match Real Usage
Only include methods you actually need:
// If your code only reads from storage:
protocol SettingsReading {
func getValue(forKey key: String) -> String?
}
// If it also writes:
protocol SettingsStoring: SettingsReading {
func setValue(_ value: String, forKey key: String)
}
A class that only reads shouldn't see write methods.
Injection Patterns
Constructor Injection (Preferred)
class CheckoutViewModel {
private let cart: CartProviding
private let payment: PaymentProcessing
private let analytics: AnalyticsTracking
init(
cart: CartProviding,
payment: PaymentProcessing,
analytics: AnalyticsTracking
) {
self.cart = cart
self.payment = payment
self.analytics = analytics
}
}
Pros:
- Dependencies are clear and required
- Object is fully configured after init
- Easy to see what a class needs
Constructor with Defaults
init(
cart: CartProviding = CartService.shared,
payment: PaymentProcessing = StripeProcessor(),
analytics: AnalyticsTracking = FirebaseAnalytics.shared
) {
self.cart = cart
self.payment = payment
self.analytics = analytics
}
Pros:
- Production code doesn't need to specify dependencies
- Tests can override what they need
- Gradual migration from singletons
Property Injection
class CheckoutViewModel {
var cart: CartProviding = CartService.shared
var payment: PaymentProcessing = StripeProcessor()
}
// In test:
let viewModel = CheckoutViewModel()
viewModel.cart = MockCart()
viewModel.payment = MockPayment()
Use sparingly—dependencies aren't guaranteed to be set.
Default Implementations
For optional behavior, provide defaults in protocol extensions:
protocol AnalyticsTracking {
func track(event: String)
func track(event: String, properties: [String: Any])
}
extension AnalyticsTracking {
func track(event: String) {
track(event: event, properties: [:])
}
}
Now conforming types only need to implement the full version.
Composing Dependencies
When a class has many dependencies, group related ones:
// Instead of 6 separate dependencies:
struct CheckoutDependencies {
let cart: CartProviding
let payment: PaymentProcessing
let shipping: ShippingCalculating
let inventory: InventoryChecking
let analytics: AnalyticsTracking
let logger: Logging
}
class CheckoutViewModel {
private let deps: CheckoutDependencies
init(deps: CheckoutDependencies) {
self.deps = deps
}
}
Provide a factory for production defaults:
extension CheckoutDependencies {
static var production: CheckoutDependencies {
CheckoutDependencies(
cart: CartService.shared,
payment: StripeProcessor(),
shipping: ShippingService(),
inventory: InventoryService.shared,
analytics: FirebaseAnalytics.shared,
logger: OSLog.default
)
}
}
Testing Benefits
With protocol-based DI:
func test_checkout_withEmptyCart_showsError() async {
// Arrange
let mockCart = MockCart()
mockCart.items = [] // Empty cart
let viewModel = CheckoutViewModel(
cart: mockCart,
payment: MockPayment(),
analytics: MockAnalytics()
)
// Act
await viewModel.checkout()
// Assert
XCTAssertEqual(viewModel.errorMessage, "Cart is empty")
}
func test_checkout_tracksPurchaseEvent() async {
// Arrange
let mockAnalytics = MockAnalytics()
let viewModel = CheckoutViewModel(
cart: MockCart(items: [.sample]),
payment: MockPayment(shouldSucceed: true),
analytics: mockAnalytics
)
// Act
await viewModel.checkout()
// Assert
XCTAssertTrue(mockAnalytics.trackedEvents.contains("purchase_completed"))
}
Each test controls exactly what it needs. No real services, no flaky behavior.
The Rule
Depend on protocols, not concrete types. Protocols define contracts. Concrete types fulfill them. When you depend on protocols, you can swap implementations—for testing, for different environments, or for future changes. This is the foundation of testable, flexible code.