How to achieve dependency injection manually in Swift

Mar 24, 2024#swift#patterns

Dependency injection (DI) is a design pattern in software development where components are given their dependencies rather than creating them internally. This pattern aims to reduce coupling between components and make them more modular, testable, and maintainable.

The goal of dependency injection is to make implicit dependencies explicit.

In DI, instead of a class creating its dependencies, the dependencies are provided to it from an external source, typically through constructor injection, property injection, or method injection.

Benefits of using constructor injection:

  • Separation of concerns: Objects can focus on their primary responsibilities without worrying about how their dependencies are created or managed.
  • Testability: You can easily replace real dependencies with mock objects during testing, leading to more reliable and isolated tests.
  • Flexibility: Dependencies can be swapped out or changed without modifying the objects that rely on them.
  • Reduced coupling: Objects are not tightly coupled to their dependencies, making code maintenance easier

Whether you opt for DI frameworks (Swinject, Needle, Cleanse) or manual DI, the goal remains the same. Here we will focus on three primary ways to achieve dependency injection manually.

Constructor injection

This approach involves creating a designated initializer that accepts the dependencies as parameters, and then storing those dependencies as properties on the object.

// Protocol defining the math library behavior
protocol MathLibrary {
    func add(_ a: Int, _ b: Int) -> Int
    func multiply(_ a: Int, _ b: Int) -> Int
}

// Class implementing the basic math library behavior
class BasicMathLibrary: MathLibrary {
    func add(_ a: Int, _ b: Int) -> Int {
        return a + b
    }
    
    func multiply(_ a: Int, _ b: Int) -> Int {
        return a * b
    }
}

// Class that requires a math library dependency through constructor injection
class Calculator {
    let mathLibrary: MathLibrary
    
    // Constructor accepting a math library dependency
    init(mathLibrary: MathLibrary) {
        self.mathLibrary = mathLibrary
    }
    
    // Method that performs addition using the injected math library
    func add(_ a: Int, _ b: Int) -> Int {
        return mathLibrary.add(a, b)
    }
    
    // Method that performs multiplication using the injected math library
    func multiply(_ a: Int, _ b: Int) -> Int {
        return mathLibrary.multiply(a, b)
    }
}

// Example usage
let mathLibrary = BasicMathLibrary() // Create an instance of math library
let calculator = Calculator(mathLibrary: mathLibrary) // Injecting dependency through constructor

// Perform addition and multiplication using the calculator
let resultAddition = calculator.add(5, 3) // Should return 8
let resultMultiplication = calculator.multiply(4, 6) // Should return 24

print("Addition result: \(resultAddition)")
print("Multiplication result: \(resultMultiplication)")

// Output:
// Addition result: 8
// Multiplication result: 24

Property injection

While constructor injection is more explicit, property injection can be useful for optional or dynamic dependencies after the object has been initialized.

// Protocol defining the logging behavior
protocol Logging {
    func log(message: String)
}

// Class implementing the logging behavior using NSLog
class ConsoleLogger: Logging {
    func log(message: String) {
        print("[Console] \(message)")
    }
}

// Class implementing the logging behavior using print
class PrintLogger: Logging {
    func log(message: String) {
        print("[Print] \(message)")
    }
}

// Class that utilizes the logging behavior through property injection
class LogManager {
    // Property for injecting the logger dependency
    var logger: Logging?
    
    // Method that logs a message using the injected logger
    func logMessage(message: String) {
        if let logger = logger {
            logger.log(message: message)
        } else {
            print("No logger set")
        }
    }
}

// Example usage
let consoleLogger = ConsoleLogger()
let logManager = LogManager()
logManager.logger = consoleLogger // Injecting dependency here
logManager.logMessage(message: "This is a log message")

// Output:
// [Console] This is a log message

Method injection

Unlike constructor injection, which happens during object initialization, method injection allows you to provide dependencies dynamically. This method is useful when you need to customize dependencies for specific method calls or when you want to avoid setting dependencies as properties on the object.

// Protocol defining the data fetching behavior
protocol DataFetching {
    func fetchData() -> String
}

// Class implementing the data fetching behavior
class DataFetcher: DataFetching {
    func fetchData() -> String {
        // Simulate fetching data from a remote server
        return "Data fetched from remote server"
    }
}

// Another class that utilizes the data fetching behavior through method injection
class DataManager {
    // Method that takes a dependency implementing DataFetching protocol
    func processData(fetcher: DataFetching) {
        let data = fetcher.fetchData()
        print("Processed data: \(data)")
    }
}

// Example usage
let dataFetcher = DataFetcher()
let dataManager = DataManager()
dataManager.processData(fetcher: dataFetcher) // Injecting dependency here

// Output:
// Processed data: Data fetched from remote server