Popular Searches
Popular Course Categories
Popular Courses

Implementing Dependency Injection in Swift Applications

Implementing Dependency Injection in Swift Applications

iOS Architecture and State Management

Implementing Dependency Injection in Swift Applications

Course: iOS App Development

Topic: Dependency Injection in Swift Applications

Course Link: iOS App Development Training at JustAcademy

Demo Link: Register for a Course Demo

Website: JustAcademy


1. Introduction to Dependency Injection

Dependency Injection (DI) is a software design technique in which an object receives the services, objects, or resources it needs from an external source instead of creating those dependencies internally. In Swift applications, dependency injection helps developers build code that is flexible, reusable, maintainable, and easy to test.

For example, a login screen may need an authentication service, a product screen may need an API client, and a settings screen may need a storage service. Instead of creating these dependencies directly inside each screen or ViewModel, the application can provide them through an initializer, a property, or a method.

Dependency injection is commonly used with the Model-View-ViewModel (MVVM) architecture, UIKit, SwiftUI, networking services, database access, persistence, analytics, and unit testing.

Learning Objectives

  • Understand dependencies and dependency injection in Swift.
  • Identify tightly coupled code and learn how to refactor it.
  • Implement initializer, property, and method injection.
  • Use protocols to define replaceable dependencies.
  • Apply dependency injection in UIKit and SwiftUI applications.
  • Inject API clients, repositories, and storage services.
  • Write unit tests using mock and fake dependencies.
  • Understand dependency containers, composition roots, and common DI mistakes.

2. What Is a Dependency?

A dependency is any object or service that another object needs to perform its work. For example, a ProfileViewModel might depend on a UserService to load profile information.

import Foundation

struct User {
    let id: Int
    let name: String
}

final class UserService {
    func fetchUser() -> User {
        User(id: 1, name: "Alex")
    }
}

final class ProfileViewModel {
    private let userService = UserService()

    func loadUserName() -> String {
        userService.fetchUser().name
    }
}

In this example, ProfileViewModel creates its own UserService. It is directly coupled to that concrete implementation, making it difficult to substitute a fake service during testing or switch to a different data source.

3. Why Is Dependency Injection Important?

  • Testability: replace real network or database services with predictable test doubles.
  • Loose coupling: let a class depend on an interface or protocol rather than a specific implementation.
  • Maintainability: change implementations without rewriting every consumer.
  • Reusability: use the same service in multiple screens or features.
  • Configurability: provide different implementations for development, testing, and production.
  • Separation of concerns: keep object construction separate from the business logic that uses the object.

4. Refactoring Tight Coupling into Dependency Injection

A class becomes tightly coupled when it constructs the concrete dependencies it uses. One improvement is to define a protocol and inject an implementation that conforms to it.

4.1 Define a Protocol

import Foundation

struct User {
    let id: Int
    let name: String
}

protocol UserServiceProtocol {
    func fetchUser() async throws -> User
}

The protocol describes what the consumer needs without specifying how the data must be retrieved. The implementation might use a remote API, a local database, or in-memory test data.

4.2 Create a Concrete Implementation

final class LiveUserService: UserServiceProtocol {
    func fetchUser() async throws -> User {
        // Replace with a real API or database operation.
        User(id: 1, name: "Alex")
    }
}

4.3 Inject the Dependency into the ViewModel

@MainActor
final class ProfileViewModel {
    private let userService: UserServiceProtocol

    private(set) var user: User?
    private(set) var errorMessage: String?

    init(userService: UserServiceProtocol) {
        self.userService = userService
    }

    func loadUser() async {
        do {
            user = try await userService.fetchUser()
            errorMessage = nil
        } catch {
            errorMessage = "Unable to load the profile."
        }
    }
}

Now, ProfileViewModel does not create its own service. The caller supplies one through the initializer. This makes the ViewModel easier to test and allows different service implementations to be used without changing the ViewModel itself.

5. Types of Dependency Injection

There are three commonly discussed forms of dependency injection in Swift: initializer injection, property injection, and method injection.

Type How It Works Best Use Case
Initializer Injection Dependencies are passed when an object is initialized. Required dependencies that must exist for the object to work.
Property Injection A dependency is assigned to a property after initialization. Optional or framework-managed dependencies that cannot conveniently be supplied in an initializer.
Method Injection A dependency is passed into a specific method. A dependency needed only for one operation.

6. Initializer Injection

Initializer injection is generally the preferred option for required dependencies. It makes dependencies explicit and prevents an object from being created without the services it needs.

protocol LoggerProtocol {
    func log(_ message: String)
}

final class ConsoleLogger: LoggerProtocol {
    func log(_ message: String) {
        print(message)
    }
}

final class CheckoutService {
    private let logger: LoggerProtocol

    init(logger: LoggerProtocol) {
        self.logger = logger
    }

    func checkout() {
        logger.log("Checkout started")
        // Perform checkout operations.
    }
}

let logger = ConsoleLogger()
let checkoutService = CheckoutService(logger: logger)
checkoutService.checkout()

Advantages:

  • Required dependencies are visible in the initializer.
  • The object can be fully configured before use.
  • Dependencies can be stored as immutable properties using let.
  • Unit tests can inject a fake logger or service.

Important consideration: if a class has too many initializer parameters, it may have too many responsibilities or need a better grouping of closely related dependencies. Avoid creating an enormous dependency container simply to hide an overly complicated class.

7. Property Injection

Property injection assigns a dependency after the object has been initialized. It can be useful when a framework creates an object or when a dependency is genuinely optional. However, required dependencies may be missing when methods are called, so property injection needs careful design.

protocol AnalyticsProtocol {
    func track(_ event: String)
}

final class AnalyticsTracker: AnalyticsProtocol {
    func track(_ event: String) {
        print("Analytics event: \(event)")
    }
}

final class SettingsCoordinator {
    var analytics: AnalyticsProtocol?

    func openSettings() {
        analytics?.track("settings_opened")
    }
}

let coordinator = SettingsCoordinator()
coordinator.analytics = AnalyticsTracker()
coordinator.openSettings()

Here, analytics is optional, so the coordinator can function even if analytics is not configured. If analytics were essential for correct operation, initializer injection would usually be safer.

8. Method Injection

Method injection passes a dependency to the operation that needs it rather than storing it for the lifetime of the object.

protocol ReportExporter {
    func export(_ report: String) throws
}

final class ReportViewModel {
    func exportReport(
        _ report: String,
        using exporter: ReportExporter
    ) throws {
        try exporter.export(report)
    }
}

Method injection is useful when a dependency is needed only for a single task, when the caller should choose the implementation for each operation, or when storing that dependency as a permanent property would be unnecessary.

9. Protocol-Oriented Dependency Injection

Swift protocols are a natural way to implement dependency inversion. A consumer can depend on a protocol that describes the behavior it needs, while concrete classes or structures provide that behavior.

protocol PaymentProcessing {
    func charge(amount: Decimal) async throws
}

struct CardPaymentProcessor: PaymentProcessing {
    func charge(amount: Decimal) async throws {
        print("Processing card payment: \(amount)")
    }
}

struct MockPaymentProcessor: PaymentProcessing {
    private(set) var lastAmount: Decimal?

    func charge(amount: Decimal) async throws {
        // A real test double could record calls using a reference type.
        print("Mock payment: \(amount)")
    }
}

final class PaymentViewModel {
    private let processor: any PaymentProcessing

    init(processor: any PaymentProcessing) {
        self.processor = processor
    }

    func pay(amount: Decimal) async throws {
        try await processor.charge(amount: amount)
    }
}

The keyword any explicitly indicates an existential value conforming to a protocol in modern Swift. The precise syntax depends on the Swift language version and protocol design. A generic type parameter is another option when a concrete dependency type should be preserved at compile time.

10. Injecting an API Client into a Repository

In larger applications, dependency injection is often applied across several layers. A ViewModel can depend on a repository protocol, and the repository can depend on an API client. This separates UI presentation from data retrieval and networking details.

10.1 Define the API Client Protocol

import Foundation

protocol APIClientProtocol {
    func getData(from url: URL) async throws -> Data
}

struct URLSessionAPIClient: APIClientProtocol {
    func getData(from url: URL) async throws -> Data {
        let (data, response) = try await URLSession.shared.data(
            from: url
        )

        guard let response = response as? HTTPURLResponse,
              (200...299).contains(response.statusCode) else {
            throw URLError(.badServerResponse)
        }

        return data
    }
}

10.2 Define a Repository

protocol UserRepositoryProtocol {
    func fetchUser() async throws -> User
}

struct RemoteUserRepository: UserRepositoryProtocol {
    private let client: APIClientProtocol
    private let endpoint: URL

    init(client: APIClientProtocol, endpoint: URL) {
        self.client = client
        self.endpoint = endpoint
    }

    func fetchUser() async throws -> User {
        let data = try await client.getData(from: endpoint)
        return try JSONDecoder().decode(User.self, from: data)
    }
}

The RemoteUserRepository receives both its network client and endpoint through its initializer. In a production application, endpoint configuration should be centralized appropriately, and decoding and networking errors should be mapped into useful application-level errors.

10.3 Build the Dependency Graph

let endpoint = URL(
    string: "https://example.com/api/user"
)!

let apiClient = URLSessionAPIClient()

let repository = RemoteUserRepository(
    client: apiClient,
    endpoint: endpoint
)

let viewModel = ProfileViewModel(
    userService: repository
)

This example assumes the repository conforms to the service protocol expected by the ViewModel. In a complete project, align the repository's protocol with the ViewModel's required interface or add an adapter if their responsibilities differ.

11. Dependency Injection in SwiftUI

SwiftUI views are value types, and their lifecycle differs from UIKit view controllers. A good approach is to create dependencies at an application composition point and pass them into views or ViewModels. This keeps views from constructing networking and persistence services themselves.

11.1 Inject a ViewModel into a SwiftUI View

import SwiftUI
import Observation

@MainActor
@Observable
final class GreetingViewModel {
    private let userService: UserServiceProtocol
    private(set) var greeting = "Welcome"

    init(userService: UserServiceProtocol) {
        self.userService = userService
    }

    func loadGreeting() async {
        do {
            let user = try await userService.fetchUser()
            greeting = "Hello, \(user.name)"
        } catch {
            greeting = "Unable to load user"
        }
    }
}

struct GreetingView: View {
    @State private var viewModel: GreetingViewModel

    init(viewModel: GreetingViewModel) {
        _viewModel = State(initialValue: viewModel)
    }

    var body: some View {
        VStack(spacing: 16) {
            Text(viewModel.greeting)
                .font(.title)

            Button("Load Greeting") {
                Task {
                    await viewModel.loadGreeting()
                }
            }
        }
        .padding()
    }
}

The view receives the ViewModel through its initializer. It does not decide which network service or repository should be used. This makes the screen easier to reuse and test.

Note: @State owns the observable ViewModel in this example. If a parent is responsible for managing the ViewModel's lifetime, use an appropriate observation and ownership approach instead of accidentally creating a new instance during view reconstruction.

11.2 Provide Dependencies from the App Entry Point

import SwiftUI

@main
struct DependencyDemoApp: App {
    private let greetingViewModel: GreetingViewModel

    init() {
        let service = LiveUserService()
        greetingViewModel = GreetingViewModel(
            userService: service
        )
    }

    var body: some Scene {
        WindowGroup {
            GreetingView(viewModel: greetingViewModel)
        }
    }
}

This illustrates the composition-root idea: a single outer part of the application constructs the concrete objects and connects them. For production apps, you can move construction into a dedicated dependency container or factory to keep the app entry point small.

12. Dependency Injection in UIKit

UIKit applications often create screens through storyboards, navigation controllers, coordinators, or programmatic initializers. Dependency injection can be implemented in each approach, but the goal remains the same: provide required services before the screen uses them.

12.1 Initializer Injection into a View Controller

import UIKit

@MainActor
final class ProfileViewController: UIViewController {
    private let viewModel: ProfileViewModel
    private let nameLabel = UILabel()

    init(viewModel: ProfileViewModel) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError(
            "Use init(viewModel:) to create this view controller"
        )
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .systemBackground
        nameLabel.textAlignment = .center
        nameLabel.text = "Profile"

        view.addSubview(nameLabel)
        nameLabel.translatesAutoresizingMaskIntoConstraints = false

        NSLayoutConstraint.activate([
            nameLabel.centerXAnchor.constraint(
                equalTo: view.centerXAnchor
            ),
            nameLabel.centerYAnchor.constraint(
                equalTo: view.centerYAnchor
            )
        ])
    }
}

This example demonstrates dependency delivery rather than the full state-binding implementation. In a complete screen, the controller would also observe ViewModel state and update labels, tables, or collection views when that state changes.

12.2 Create the Screen from a Coordinator

import UIKit

@MainActor
final class AppCoordinator {
    private let navigationController: UINavigationController
    private let userService: UserServiceProtocol

    init(
        navigationController: UINavigationController,
        userService: UserServiceProtocol
    ) {
        self.navigationController = navigationController
        self.userService = userService
    }

    func showProfile() {
        let viewModel = ProfileViewModel(
            userService: userService
        )
        let viewController = ProfileViewController(
            viewModel: viewModel
        )

        navigationController.pushViewController(
            viewController,
            animated: true
        )
    }
}

The coordinator constructs the ViewModel and view controller using the service it has received. This keeps navigation and object construction outside the screen itself.

12.3 Dependency Injection with Storyboards

Storyboard-based UIKit screens are often instantiated by UIKit rather than through a custom initializer call. Several approaches can help:

  • Use a coordinator or factory that instantiates the storyboard screen and supplies its dependencies before the screen performs its work.
  • Use a property or configuration method when initializer injection is impractical, and make sure configuration occurs before the screen is used.
  • Use a custom creator or supported storyboard-instantiation mechanism in projects that need stricter initialization guarantees.

For essential dependencies, avoid allowing a screen to operate in a partially configured state. If a dependency is required, fail clearly during configuration or use an object-creation path that guarantees it is present.

13. Creating a Simple Dependency Container

A dependency container is an object that constructs and provides shared services. It is useful when an application has several screens and multiple services that depend on one another.

import Foundation

@MainActor
final class AppDependencies {
    let userService: UserServiceProtocol

    init(
        userService: UserServiceProtocol = LiveUserService()
    ) {
        self.userService = userService
    }

    func makeProfileViewModel() -> ProfileViewModel {
        ProfileViewModel(userService: userService)
    }
}

Example usage in an application:

let dependencies = AppDependencies()

let profileViewModel =
    dependencies.makeProfileViewModel()

This container provides a single place to configure the service implementation and construct ViewModels. Its dependencies can also be replaced in tests.

13.1 Dependency Lifetimes

Lifetime Meaning Example
Transient A new instance is created for each resolution or request. A short-lived formatter or operation object.
Shared The same instance is reused within a defined scope. An application-level API client or analytics service.
Screen-scoped The dependency is created for a screen or feature and released with that scope. A feature-specific ViewModel or temporary editing session.

These are design concepts rather than special Swift keywords. Choose the lifetime based on ownership, resource usage, and the intended behavior of the object. Do not make every service a global singleton by default.

14. Dependency Injection and Unit Testing

One of the strongest benefits of dependency injection is the ability to test business and presentation logic without relying on live APIs, databases, or third-party services.

14.1 Create a Fake Service

struct FakeUserService: UserServiceProtocol {
    let result: Result<User, Error>

    func fetchUser() async throws -> User {
        try result.get()
    }
}

The fake service can return known data or a controlled error. This allows tests to cover multiple scenarios without depending on network conditions.

14.2 Test Successful Loading

import XCTest
@testable import YourAppModule

@MainActor
final class ProfileViewModelTests: XCTestCase {
    func testLoadUserUpdatesGreetingData() async {
        let expectedUser = User(id: 1, name: "Alex")
        let service = FakeUserService(
            result: .success(expectedUser)
        )
        let viewModel = ProfileViewModel(
            userService: service
        )

        await viewModel.loadUser()

        XCTAssertEqual(viewModel.user?.name, "Alex")
        XCTAssertNil(viewModel.errorMessage)
    }
}

Replace YourAppModule with the actual module name. This test assumes the ViewModel has the user and errorMessage properties shown in the earlier implementation.

14.3 Test Failure Handling

enum TestError: Error {
    case simulatedFailure
}

func makeFailingUserService() -> FakeUserService {
    FakeUserService(
        result: .failure(TestError.simulatedFailure)
    )
}

Use the failing service to verify that the ViewModel presents an appropriate error state and does not incorrectly report a successful result. In a larger test suite, separate the success and failure tests and also check loading-state behavior.

14.4 Mock, Fake, and Stub

Test Double Purpose Example
Stub Returns predefined data. A service that always returns one sample user.
Fake Provides a simplified working implementation. An in-memory repository for tests.
Mock Records interactions so tests can verify calls and arguments. A payment processor mock that records the charged amount.
Spy Wraps or records behavior for later inspection. A logger that stores messages written by a ViewModel.

These terms are sometimes used differently by teams, but all describe ways to replace real dependencies with controlled implementations during testing.

15. Dependency Injection Without Protocols

Protocols are useful when multiple implementations are needed, but they are not mandatory for every dependency. A concrete type can be injected directly when the abstraction adds no meaningful value.

struct DateProvider {
    var now: () -> Date

    static let live = DateProvider(now: Date.init)
}

struct ExpirationChecker {
    private let dateProvider: DateProvider

    init(dateProvider: DateProvider) {
        self.dateProvider = dateProvider
    }

    func isExpired(_ expirationDate: Date) -> Bool {
        dateProvider.now() > expirationDate
    }
}

Because the current date is provided as a closure, tests can inject a predictable date. This avoids depending on the real system clock when verifying expiration behavior.

16. Dependency Injection vs. Service Locator

Dependency injection and the service locator pattern both help objects obtain services, but they expose dependencies differently.

Dependency Injection Service Locator
Dependencies are supplied to the object. The object asks a registry or locator to find dependencies.
Dependencies are usually visible in initializers or method signatures. Dependencies may be less obvious from the object's public interface.
Often makes unit tests and object construction easier to understand. Can be convenient in legacy systems or framework-constrained environments.
Encourages explicit object construction at an outer composition point. Requires careful management of the locator's registrations and access.

For most straightforward Swift application code, explicit initializer injection is a good starting point. A service locator can be useful in certain systems, but widespread hidden access to a global registry can make dependencies difficult to discover and test.

17. Using a Dependency Injection Framework

Many applications can use manual dependency injection with initializers and a small container. As an application grows, a framework can help register services, resolve dependency graphs, and manage object scopes.

One Swift framework is Swinject. It supports registering and resolving dependencies, including different injection patterns and object scopes.

Before adopting a framework, consider:

  • How many services and dependency relationships the application has.
  • Whether manual construction is still understandable and maintainable.
  • How the framework handles object lifetimes and circular dependencies.
  • Whether registrations remain easy to trace and test.
  • Whether the additional dependency is justified by the project's complexity.

A framework should simplify dependency management rather than conceal a confusing architecture. Understand manual injection first so you can reason about the objects the framework creates.

18. Common Dependency Injection Mistakes

Mistake Why It Is a Problem Recommended Approach
Creating services inside every ViewModel Consumers become tightly coupled to implementations. Inject required dependencies through initializers.
Using optional dependencies for everything Objects can be created without required services and fail later. Use non-optional initializer parameters for essential dependencies.
Making every dependency a singleton Global state can cause test interference and unclear lifetimes. Choose lifetimes deliberately and prefer explicit ownership.
Building an oversized container One object becomes responsible for constructing the entire application. Split construction into focused factories or feature containers when appropriate.
Adding protocols without a purpose Extra abstractions increase complexity without useful flexibility. Introduce protocols where substitution, modularity, or testing benefits justify them.
Using property injection for required dependencies The dependency may not be set before the object is used. Prefer initializer injection when possible.
Creating circular dependencies Objects may be impossible to construct cleanly and can become tightly coupled. Revisit responsibilities and introduce a mediator or shared abstraction if needed.
Making the ViewModel depend on UI controls Presentation logic becomes coupled to UIKit or SwiftUI. Keep ViewModels focused on state and actions rather than direct view manipulation.

19. Practical Project: User Profile Application

Build a small iOS application that loads and displays user profile information. Use MVVM with dependency injection so that the data source can be changed without rewriting the screen.

Project Requirements

  • Create a User model with an identifier, name, and email.
  • Define a UserServiceProtocol describing the required data operation.
  • Create a live service that fetches data from an API.
  • Create a fake service for previews and unit tests.
  • Inject the service into ProfileViewModel.
  • Display loading, success, empty, and failure states.
  • Provide a retry action when the request fails.
  • Implement the interface in SwiftUI or UIKit.
  • Write tests for successful responses and simulated errors.

Suggested Folder Structure

UserProfileApp/
├── Models/
│   └── User.swift
├── Services/
│   ├── UserServiceProtocol.swift
│   ├── LiveUserService.swift
│   └── FakeUserService.swift
├── ViewModels/
│   └── ProfileViewModel.swift
├── Views/
│   ├── SwiftUI/
│   │   └── ProfileView.swift
│   └── UIKit/
│       └── ProfileViewController.swift
├── Dependencies/
│   └── AppDependencies.swift
└── Tests/
    └── ProfileViewModelTests.swift

This is a suggested structure. Adjust it to match the size of the project and your team's conventions.

20. Practice Exercises

  1. Refactor a ViewModel that creates its own API service to use initializer injection.
  2. Create a protocol for a local storage service and inject it into a settings ViewModel.
  3. Use method injection to pass a file exporter into a report-export operation.
  4. Create a fake service that returns a successful result and verify the ViewModel state.
  5. Create a failing service and test error handling and retry behavior.
  6. Build a dependency container that creates a service, repository, and ViewModel.
  7. Implement the same injected ViewModel in SwiftUI and UIKit.
  8. Write a test that injects a fixed date provider to verify expiration logic.

21. Interview Questions and Answers

Q1. What is dependency injection in Swift?

Dependency injection is a design technique in which an object receives the dependencies it needs from an external source instead of creating them internally.

Q2. What are the main types of dependency injection?

The three commonly discussed types are initializer injection, property injection, and method injection.

Q3. Why is initializer injection usually preferred?

It makes required dependencies explicit, ensures they are supplied during construction, and allows them to be stored as immutable properties.

Q4. Do I always need a protocol for dependency injection?

No. Protocols are valuable when you need interchangeable implementations or an abstraction boundary. A concrete type can be injected directly when that is simpler and sufficient.

Q5. How does dependency injection improve unit testing?

Tests can provide fake or mock dependencies that return predictable results, simulate failures, and record interactions without using real network or storage services.

Q6. Can dependency injection be used with SwiftUI?

Yes. Dependencies can be created at the application or feature level and passed into views or ViewModels through initializers.

Q7. Can dependency injection be used with UIKit?

Yes. UIKit applications can inject dependencies through view controller initializers, factories, coordinators, or carefully managed configuration methods.

Q8. What is a dependency container?

A dependency container is an object responsible for constructing and providing services and other objects required by the application.

Q9. What is the difference between dependency injection and a service locator?

Dependency injection supplies dependencies to an object. A service locator lets the object request dependencies from a registry, which can make those dependencies less explicit.

Q10. Should every service be a singleton?

No. Some services benefit from shared instances, while others should be created for a particular feature or operation. Choose object lifetimes based on ownership and requirements.

Q11. What is a composition root?

A composition root is the part of an application where concrete dependencies are constructed and connected before being passed to the objects that use them.

Q12. How can circular dependencies be avoided?

Review the responsibilities of the objects involved, reduce unnecessary coupling, and introduce a mediator or shared abstraction when appropriate.

22. Quick Revision Checklist

  • Identify the dependencies each class requires.
  • Prefer initializer injection for essential dependencies.
  • Use protocols when implementations need to be interchangeable.
  • Keep service creation outside ViewModels and UI components where practical.
  • Use a composition root or focused container to assemble dependencies.
  • Choose object lifetimes intentionally rather than making everything global.
  • Use fake services to test success and failure scenarios.
  • Apply dependency injection in both SwiftUI and UIKit.
  • Avoid unnecessary abstractions, optional required services, and circular dependencies.

23. Conclusion

Dependency injection is a fundamental technique for building maintainable Swift applications. By supplying services and other dependencies from outside the objects that use them, developers can reduce coupling, improve testability, and make implementations easier to replace.

Start with initializer injection, use protocols where they provide meaningful flexibility, and assemble objects at a clear composition point. As your application grows, focused dependency containers or a DI framework can help manage more complex dependency graphs. Combined with MVVM, dependency injection helps keep SwiftUI views and UIKit view controllers focused on presentation while services handle data access and other operations.

24. Additional Learning Resources

Previous Next
whatsapp