How do you avoid tight coupling?
What tight coupling actually is, precisely
Two pieces of code are tightly coupled when a change to one forces a change to the other, or when one can’t be understood, tested, or reused without dragging the other along. The goal isn’t zero coupling — it’s making dependencies explicit, minimal, and pointed at abstractions rather than concretions.
1. Depend on protocols, not concrete types — the single most load-bearing technique
// Tightly coupled
final class ProfileViewModel {
private let repository = NetworkUserRepository()
}
// Loosely coupled
final class ProfileViewModel {
private let repository: UserRepository
init(repository: UserRepository) { self.repository = repository }
}
This is what makes testing possible without a live network, and what let Clean Architecture’s inner layers stay framework-agnostic.
2. Dependency injection over singletons/global state
final class ProfileService {
private let client: HTTPClient
init(client: HTTPClient) { self.client = client }
}
Constructor injection keeps dependencies visible in the type’s signature.
3. Communicate through narrow interfaces, not shared mutable objects
Passing a whole mutable object around when only one piece of data is needed creates coupling to everything else that object exposes:
// Coupled — SettingsView can read/mutate anything on the whole User object
func showSettings(for user: User) { ... }
// Narrower — only depends on what's actually needed
func showSettings(displayName: String, onSave: @escaping (String) -> Void) { ... }
4. Decouple through events/delegation rather than direct references, where appropriate
final class UploadManager {
var onProgress: ((Double) -> Void)? // no coupling to whoever's listening
}
This is the same principle behind Coordinators’ delegate/closure pattern — the screen doesn’t know what happens after it emits an event.
5. Isolate volatile dependencies (third-party SDKs, frameworks) behind your own abstraction
protocol AnalyticsTracking {
func track(event: String, properties: [String: Any])
}
final class ThirdPartyAnalyticsAdapter: AnalyticsTracking {
func track(event: String, properties: [String: Any]) {
ThirdPartySDK.logEvent(event, properties) // the ONLY place that imports ThirdPartySDK
}
}
This is a direct application of the Adapter pattern.
6. Module/dependency-graph enforcement (the structural version)
At the scale of modularization/multi-team apps, the same principle gets enforced structurally — SPM’s dependency graph makes illegal coupling a compile error rather than a code-review hope.
7. Favor composition over inheritance
Deep inheritance hierarchies are a classic coupling trap — a subclass is coupled to its entire superclass chain’s implementation details (the fragile base class problem). Preferring protocol composition and injected collaborators keeps dependencies explicit and one level deep rather than an inherited chain of assumptions.
The judgment call — coupling isn’t always bad, and decoupling isn’t free
Introducing a protocol, an injection seam, and a mock for something that has exactly one implementation and will only ever have one is pure overhead with no payoff. The signal that coupling is actually a problem: would I need to change this code because of an unrelated change elsewhere, or would I want to substitute a different implementation (for testing, for a new requirement, for a different platform)? If neither is true, a direct concrete dependency is simply fine.
Interview-ready summary
| Technique | What it decouples |
|---|---|
| Protocol-based dependencies | Consumer from concrete implementation |
| Dependency injection (constructor) | Consumer from global/singleton lifecycle |
| Narrow interfaces (pass only what’s needed) | Consumer from incidental object surface |
| Events/delegation/closures | Emitter from knowledge of its observers |
| Adapter around third-party SDKs | App code from vendor API churn |
| Module dependency graph enforcement | Structural — makes illegal coupling a build error |
| Composition over inheritance | Subclass from superclass implementation fragility |
One-liner if pressed: “Tight coupling comes from depending on concrete implementations, global state, or wider interfaces than you actually need — so the fix across every layer, from a single ViewModel to a whole module graph, is the same move: depend on the narrowest protocol/interface that expresses what you actually need, inject it explicitly rather than reaching for a singleton, and enforce that boundary structurally where possible rather than relying on convention — but only where there’s a real reason to substitute or isolate, since abstraction for its own sake is its own form of unnecessary complexity.”