Understanding Dependency Injection in iOS with a Basic Dependency Container

Enes Buğra Yenidünya
3 min readFeb 6, 2024

In the world of iOS development, keeping our code clean, maintainable, and testable is crucial. One way to achieve this is through Dependency Injection (DI), a design pattern that might sound complex but is actually quite straightforward. Let’s break it down with a simple example: a class I’ll call BYDependencyContainer.

What is Dependency Injection?

Dependency Injection is a fancy term for a simple idea: instead of your objects creating their own dependencies, something else gives those dependencies to them. This “something else” is often a piece of code dedicated to this task, which can help make your code less tangled and easier to manage.

Why Use DI?

  • Testability: It’s easier to test objects when you can control their dependencies.
  • Flexibility: You can change parts of your app without rewriting a lot of code.
  • Simplicity: Your classes don’t need to know where their dependencies come from.

Introducing BYDependencyContainer

BYDependencyContainer is a basic DI container. It holds onto the dependencies your app needs, like services or managers, so your app's components don't have to create them. It uses a pattern called "singleton," which means there's only one instance of it in your app.

public class BYDependencyContainer {

// MARK: Properties
public static let shared = BYDependencyContainer()
private var instances = [String: Any]()

// Private initializer to prevent external instantiation
private init() {}

/// Registers an instance of a dependency.
/// The type of the instance is inferred from the instance itself.
/// - Parameter instance: The instance to be registered.
public func register<T>(_ instance: T) {
let key = String(describing: T.self)
instances[key] = instance
}

/// Resolves and returns an instance of the requested dependency type.
/// If the instance has not been registered, this method will trigger a fatal error.
/// - Returns: An instance of the requested type.
public func resolve<T>() -> T {
let key = String(describing: T.self)

guard let instance = instances[key] as? T else {
fatalError("No instance registered for \(key)")
}

return instance
}
}

How It Works?

  • Register Dependencies: You tell BYDependencyContainer about a dependency by registering it. This is usually done at the start of your app, like in the AppDelegate.
  • Resolve Dependencies: When a part of your app needs a dependency, it asks the container for it.

Example in Action

First, we set up our dependencies in the AppDelegate. It might look something like this:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

// Registering dependencies
let networkService = NetworkService()
BYDependencyContainer.shared.register(networkService)

let userManager = UserManager(networkService: networkService)
BYDependencyContainer.shared.register(userManager)

return true
}

Now, whenever a part of your app needs one of these dependencies, it can simply ask for it:

class UserProfileViewController: UIViewController {

private var userManager: UserManager!

override func viewDidLoad() {
super.viewDidLoad()

userManager = BYDependencyContainer.shared.resolve()
}
}

In this case, UserProfileViewController gets UserManager from the container, keeping it decoupled from the creation logic.

Conclusion

Dependency Injection doesn’t have to be complicated. With a basic understanding and a simple class like BYDependencyContainer, you can make your iOS projects more manageable and maintainable. This pattern helps in separating concerns, making your code cleaner and your life as a developer a bit easier.

Follow me on Twitter, GitHub and Linkedin.

--

--

Enes Buğra Yenidünya

iOS Engineer — Freelancer #iOS #swift #mobileappdevelopment #software #apple