How dependency injection helpful in loosely coupling architectures?


We know that Dependency Injection is used to achieve loose coupling between components of an application. The main idea behind DI is to separate the creation of an object from its usage by injecting dependencies into a class, rather than having the class itself manage its dependencies.
How dependency injection helps loose coupling?
Loosely Coupled Architecture: Classes focus on their core functionality rather than managing their dependencies.
Interchangeable Components: Since dependencies are injected as abstractions (protocols or interfaces), different implementations can be swapped in without changing the dependent class.
Easier Refactoring: Changes in one part of the code (like swapping out a service) don’t ripple through the codebase because the class doesn’t directly depend on the service’s concrete implementation.
Let’s say you are building an app that sends notifications to users. There could be multiple ways of notifying users, such as via email, SMS, or push notifications. To make this system flexible, we can use dependency injection to avoid tightly coupling our notification logic with any specific type of notification.
Without Dependency Injection (Tightly Coupled System)
class EmailNotification {
func sendEmail(to user: String) {
print("Sending email to \(user)")
}
}
class UserNotificationService {
private let emailNotification = EmailNotification()
func notifyUser(user: String) {
emailNotification.sendEmail(to: user)
}
}
In this example:
UserNotificationService
is tightly coupled toEmailNotification
. If we decide to add SMS notifications or push notifications, we would need to modifyUserNotificationService
, leading to a ripple effect of changes across the app.The class is also hard to test because it directly creates an instance of
EmailNotification
, making it difficult to substitute this with a mock or alternative implementation in tests.
With Dependency Injection (Loosely Coupled System)
Now let’s refactor this system using dependency injection. We’ll start by introducing a protocol that abstracts the notification behavior.
Define a Protocol:
protocol NotificationService {
func notify(user: String)
}
We’ll create multiple implementations for different notification types, each conforming to the NotificationService
protocol like below:
class EmailNotification: NotificationService {
func notify(user: String) {
print("Sending email to \(user)")
}
}
class PushNotification: NotificationService {
func notify(user: String) {
print("Sending push notification to \(user)")
}
}
Now, each notification type implements the NotificationService
protocol. This way, we can use any of these services interchangeably without changing the core logic of user notifications.
Refactor UserNotificationService
to use dependency injection:
class UserNotificationService {
private let notificationService: NotificationService
// Constructor Injection
init(notificationService: NotificationService) {
self.notificationService = notificationService
}
func notifyUser(user: String) {
notificationService.notify(user: user)
}
}
In the above code:
Constructor Injection is used here, where
UserNotificationService
receives theNotificationService
dependency through its initializer.The service doesn’t need to know or care whether it’s sending an email, SMS, or push notification. All it knows is that it will notify a user using the provided
NotificationService
implementation.
// email notification
let emailNotificationService = EmailNotification()
let userNotificationService1 = UserNotificationService(notificationService:
emailNotificationService)
userNotificationService1.notifyUser(user: "Swiftable")
// Output: Sending email to Swiftable
// push notification
let pushNotificationService = PushNotification()
let userNotificationService3 = UserNotificationService(notificationService:
pushNotificationService)
userNotificationService3.notifyUser(user: "Swiftable")
// Output: Sending push notification to Swiftable
In this code:
We can easily swap between different notification services (email, SMS, push) by injecting the desired implementation into UserNotificationService.
The UserNotificationService class remains unchanged regardless of the type of notification it sends.
How dependency injection helping here in loosely coupling architectures?
Loosely Coupled Architecture:
The UserNotificationService is not directly dependent on any specific notification implementation. It only knows about the NotificationService protocol, making it loosely coupled.
If new notification methods are required (like a WhatsApp or Slack notification), they can be added without modifying the existing code in UserNotificationService.
Flexibility and Extensibility:
- You can extend the system by adding new types of notifications (such as PushNotification), without touching the code that uses these services (i.e., UserNotificationService).
Testability:
- During testing, we can provide a mock implementation of NotificationService to verify the behavior of UserNotificationService, without relying on real email or SMS services.
Example of Testing Using a Mock:
class MockNotification: NotificationService {
func notify(user: String) {
print("Mock notification to \(user)")
}
}
let mockService = MockNotification()
let userNotificationService = UserNotificationService(notificationService: mockService)
userNotificationService.notifyUser(user: "Test User")
// Output: Mock notification to Test User
By using MockNotification
, we can easily test the behavior of UserNotificationService
without sending actual notifications, making unit tests fast and independent of external services.
Important Factors:
Abstraction over Implementation: By depending on interfaces (protocols in Swift), the system is more flexible, and individual components can be changed independently.
Encapsulation: Dependency Injection allows a class to be responsible only for its core behavior, while the creation of dependencies is handled externally.
Testing: By injecting dependencies, it's easy to substitute mocks or stubs in unit tests, isolating the class under test.
By using dependency injection, you decouple components in your architecture, making your system more modular and adaptable to change. This leads to greater flexibility and testability, as dependencies can be swapped in and out without rewriting entire classes.
Start your preparation for iOS interviews with “iOS Interview Handbook“, includes:
✅ 290+ top interview Q&A covering a wide range of topics
✅ 520+ pages with examples in most of the questions
✅ Expert guidance and a roadmap for interview preparation
✅ Exclusive access to a personalised 1:1 session for doubts
Thank you for taking the time to read this article! If you found it helpful, don’t forget to like, share, and leave a comment with your thoughts or feedback. Your support means a lot!
Keep reading,
— Swiftable
Subscribe to my newsletter
Read articles from Nitin directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Nitin
Nitin
Lead Software Engineer, Technical Writer, Book Author, A Mentor