Post

Inversion of Control Design Principle

Inversion of Control Design Principle

Inversion of Control Design Principle

In a previous post, I mentioned about dependency injection (DI) here. I also talked about the lifetime of DI objects.

But I realized that I should also focus on the broad principle, Inversion of Control, as DI is part of IoC. So in this post I will detail my understanding of IoC, and also describe the other types of IoC.

What is Inversion of Control

Inversion of Control (IoC) is a broad software design principle where the control of execution flow, object creation, or lifecycle management is inverted. Instead of your application controlling the flow of the program, control is given to a framework, container, or external runtime.

What does that even mean? Flow is so generic - which part?

Here, control generally refers to who decides when a piece of code runs, how an object is created, or how dependencies are wired together. Hence, in this case, the control can refer to:

  1. Execution flow: who decides when a piece of code runs
  2. Object creation: who decides how an object is created
  3. Dependency wiring: who decides how dependencies are wired together

In essence, objects do not create other objects on which they rely to do their work. One way to realize IoC is that objects get the objects that they need from an outside source (for example, an xml configuration file).

This is frequently referred to as the Hollywood Principle: “Don’t call us, we’ll call you.”

Relation to Dependency Inversion Principle (Architectural Guideline)

Let’s talk abit about dependency inversion. With terms like LoC, DI (dependency injection) and DIP (dependency inversion principle), I too get confused as they all sound the same. However, a Google search says they’re not the same.

Dependency Inversion is the ‘D’ in SOLID principles. It states that:

  • High-level modules should not depend on low-level modules.
  • Both should depend on abstractions (interfaces).
  • Abstractions should not depend on details; details should depend on abstractions.

Rather, this principle is about how code should relate to its dependences. High level modules (business logic) and low-level modules (database access, UI) should both depend on abstractions (interfaces) rather than on each other directly. This reduces tight coupling, making the system easier to maintain and test.

In other words, your business logic should only know about interfaces, not concrete implementations.

Difference from IoC: DIP is a principle ensuring modules depend on abstractions, not details. But IoC is about inverting control from application to the framework/container.

  • IoC is the runtime philosophy you use to make DIP possible. To stop your class from depending on concrete details, you must strip that class of its power to instantiate its own dependencies (new MySqlDatabase()). Control is inverted and handed over to an external entity (a framework or a container).

So how do they relate to each other?

Interestingly, If you try to follow DIP strictly (“depend only on interfaces”), you immediately hit a paradox in standard procedural code: Someone, somewhere, eventually has to instantiate the concrete class. If your business logic creates the concrete database instance, it breaks DIP by depending on a detail.

By applying IoC, you solve this paradox. You hand over the responsibility of creating that concrete class to an external framework or container. Because the framework handles the “dirty work” of instantiation behind the scenes, your application code is free to purely depend on abstractions. Without IoC, you cannot fully achieve DIP.

Image

Dependency Inversion Principle & Inversion of Control

Types of Inversion of Control

Image

Types of Inversion of Control

Hence, IoC is not just one design pattern—it is a category of design patterns and paradigms. SL (Service Locator) and DI (Dependency Injection) are two design patterns stem off from IoC. Here some ways IoC is realized in software architecture:

1. Dependency Injection (DI)

Instead of a class instantiating its own dependencies using new, the dependencies are passed (injected) to the class at runtime (typically via its constructor, properties, or methods).

  • How control is inverted: The responsibility of creating and wiring up dependencies is taken away from the consumer class and given to an assembler or DI container (e.g., Spring IoC, Dagger, or NestJS).
  • Read more: Check out my detailed writeup on dependency injection (DI).

2. Service Locator

A Service Locator acts as a centralized registry that wraps the resolution of dependencies. Instead of injecting dependencies directly, classes query the Service Locator to retrieve the instances they need.

  • How control is inverted: The creation and lookup of dependencies are delegated to the locator, isolating the class from the concrete implementations.

Example: Objective-C service locator implementation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// ServiceLocator.h
#import <Foundation/Foundation.h>

@interface ServiceLocator : NSObject

+ (instancetype)sharedInstance;

- (void)registerService:(id)service forName:(NSString *)name;
- (id)getServiceForName:(NSString *)name;

@end

// ServiceLocator.m
#import "ServiceLocator.h"

@interface ServiceLocator ()
@property (nonatomic, strong) NSMutableDictionary<NSString *, id> *services;
@end

@implementation ServiceLocator

+ (instancetype)sharedInstance {
    static ServiceLocator *sharedInstance = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        sharedInstance = [[self alloc] init];
    });
    return sharedInstance;
}

- (instancetype)init {
    self = [super init];
    if (self) {
        _services = [[NSMutableDictionary alloc] init];
    }
    return self;
}

- (void)registerService:(id)service forName:(NSString *)name {
    if (service && name) {
        @synchronized (self.services) {
            self.services[name] = service;
        }
    }
}

- (id)getServiceForName:(NSString *)name {
    if (!name) return nil;
    @synchronized (self.services) {
        return self.services[name];
    }
}

@end

Assuming a protocol (interface) and a logger class:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Protocols and Interfaces
@protocol LoggerProtocol <NSObject>
- (void)log:(NSString *)message;
@end

@interface ConsoleLogger : NSObject <LoggerProtocol>
@end

@implementation ConsoleLogger
- (void)log:(NSString *)message {
    NSLog(@"[Log]: %@", message);
}
@end

The consuming class “pulls” the dependency from the locator:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// PaymentProcessor.m
#import "ServiceLocator.h"

@interface PaymentProcessor : NSObject
- (void)processPayment;
@end

@implementation PaymentProcessor

- (void)processPayment {
    // Pull the dependency out of the locator registry
    id<LoggerProtocol> logger = [[ServiceLocator sharedInstance] getServiceForName:@"logger"];
    
    [logger log:@"Processing payment..."];
}

@end

In the class method (+) implementation, the ServiceLocator functions as a pure utility registry rather than an instantiated object:

  • Pure Static Behavior: By using file-scoped static variables (static NSMutableDictionary) and class methods (+), the locator bypasses object allocation completely. It mirrors the Java static Map design perfectly.
  • Global Access Point: Consuming classes invoke the registry directly via the class type ([ServiceLocator getServiceForName:…]).
  • The “Pull” Anti-Pattern: Because the consumption occurs implicitly inside the method body via a global class call, it hides the dependency from the object’s public interface, which remains the core architectural trade-off of the Service Locator pattern.

3. Event-Driven Architecture / Callbacks

In event-driven designs, components communicate asynchronously by emitting and reacting to events or messages.

  • How control is inverted: Rather than a central class synchronously orchestrating steps, execution flow is driven by the state changes of the system. Components register interest (subscribers/listeners) in certain events, and the message broker or event loop invokes them when those events occur.
  • Example: JavaScript’s event loop or UI button click listeners (button.onClick(event => { ... })).

This is IoC because the flow of control is inverted - instead of the main thread orchestrating all steps and invoking other classes, the framework or event loop orchestrates the flow of control and invokes the registered callbacks.

4. Strategy Pattern

The Strategy Pattern allows behavior to be switched at runtime by passing different strategies. A context object holds a reference to a strategy interface and delegates tasks to it.

  • How control is inverted: The host class (Context) does not hardcode the behavior or decide which algorithm to run. Instead, the control over how a specific task is performed is inverted to the concrete strategy object passed in at runtime.
  • Example: Sorting algorithms.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Strategy interface
interface SortStrategy {
    void sort(int[] array);
}

// Concrete strategies
class QuickSort implements SortStrategy {
    public void sort(int[] array) {
        System.out.println("Sorting using QuickSort");
    }
}

class MergeSort implements SortStrategy {
    public void sort(int[] array) {
        System.out.println("Sorting using MergeSort");
    }
}

// Context class using the strategy
class Sorter {
    private SortStrategy strategy;
    
    public void setStrategy(SortStrategy strategy) {
        this.strategy = strategy;
    }
    
    public void performSort(int[] array) {
        strategy.sort(array);
    }
}

This is IoC because the client code delegates behavior to an interface, letting an external component decide which strategy to execute at runtime.

5. Delegate Pattern

The Delegate Pattern enables an object to hand over (delegate) some of its decisions, configurations, or event responses to another object (the delegate) that conforms to a specified protocol or interface.

  • How control is inverted: Instead of the delegator class controlling its own state transitions, UI rendering, or event handling, it leaves these decisions to its delegate. The delegator simply acts as a container, invoking the delegate’s methods at key lifecycle events.
  • Example: A file downloader class delegating progress updates and completion actions:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Swift Delegate Protocol
protocol FileDownloaderDelegate: AnyObject {
    func downloader(_ downloader: FileDownloader, didUpdateProgress progress: Double)
    func downloader(_ downloader: FileDownloader, didFinishWithData data: Data)
}

class FileDownloader {
    weak var delegate: FileDownloaderDelegate?
    
    func startDownload() {
        // ... Downloading steps
        delegate?.downloader(self, didUpdateProgress: 0.5)
        // ... Finished downloading
        let downloadedData = Data() // Dummy data
        delegate?.downloader(self, didFinishWithData: downloadedData)
    }
}

It is a classic form of IoC as it inverts the control of the workflow - instead of the FileDownloader orchestrating the entire workflow, the delegate is responsible for handling the progress updates and completion actions. The delegate class decides when to call you. (Hollywood Principle).

Dependency Injection vs Service Locator (Push vs. Pull Model)

  • Push Model (Dependency Injection): The client class is entirely passive. It declares what it needs (typically in its constructor) and does not know how the dependency is created. The container or runner “pushes” the concrete implementations into the class.
  • Pull Model (Service Locator): The client class is active. It explicitly requests the dependency by calling a registry or locator to “pull” the dependency into its scope.

While both satisfy the Inversion of Control principle, Dependency Injection is widely preferred in modern software design due to several critical trade-offs:

1. API Transparency

  • Push Model (DI) — Transparent: Dependencies are explicitly declared in the constructor signature. Anyone reading the code immediately knows what the class needs to function.
  • Pull Model (Service Locator) — Hidden: The class constructor appears to have no dependencies, but the method bodies secretly query a global locator. This leads to “lying APIs” that fail at runtime if a service is missing.

2. Coupling

  • Push Model (DI) — Loose: The client class is entirely framework-agnostic. It does not import or depend on any DI container classes.
  • Pull Model (Service Locator) — Tight: The client class must import and reference the ServiceLocator type, coupling it to the locator infrastructure.

3. Unit Testing

  • Push Model (DI) — Simple: You can test the class by passing mock dependencies directly into the constructor: new PaymentProcessor(mockLogger). No framework setup is required.
  • Pull Model (Service Locator) — Complex: You must initialize the global ServiceLocator state with mock dependencies before running tests, and carefully tear it down afterwards to prevent test pollution.

4. State Isolation

  • Push Model (DI) — Excellent: Instances are isolated. You can easily spin up multiple independent instances of the class with different dependency mocks in parallel.
  • Pull Model (Service Locator) — Poor: The shared global registry state makes it difficult to run tests in parallel, often leading to flaky test suites.

In short, the pull model (Service Locator) trades architectural purity and ease of testing for temporary convenience, which is why it is often classified as a modern anti-pattern. The push model (DI) keeps components pure, decoupled, and self-documenting.

However, in certain cases, pull is the better pragmatic fit, because of reasons such as:

  • Feature surface being huge and optional - push would bloat constructors and churn bindings when new features are added.
  • Dynamic runtime instantiation (Factories) — When objects are created dynamically at runtime (e.g., creating a UI widget or connection session based on user actions), a pure DI factory must receive and forward all of the instantiated object’s dependencies. This creates “pass-through dependencies” where the factory acts as a courier for services it doesn’t use. A locator lets dynamically created objects pull their own dependencies, keeping the factory constructor clean.
  • Lifetime is already page-scoped (e.g. mobile applications) - the global-locator smell is partly mitigated if the locator is itself local to the page.

Image

Dependency Inversion Principle & Inversion of Control

Summary

At its core, Inversion of Control is about shifting responsibility away from your custom code to make it more modular, testable, and loosely coupled. By relinquishing control over when things run or how objects are constructed, you create highly flexible systems that are easy to extend and maintain.

References

  • https://martinfowler.com/articles/dipInTheWild.html#YouMeanDependencyInversionRight
  • https://www.c-sharpcorner.com/article/inversion-of-control-vs-dependency-injection-vs-dependency-inversion/
  • https://tarunjain07.medium.com/inversion-of-control-ioc-dependency-injection-di-9155a4151db9
This post is licensed under CC BY 4.0 by the author.