Testing Mastery

Protocol-Based Dependency Injection

March 11, 2026
6 min read
Featured image for blog post: 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.

Originally published on pixelper.com

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch