DEV Community

Jay Bhavsar
Jay Bhavsar

Posted on

Inversion of Control (IoC): Demystifying the Familiar Pattern

Senior devs, if you are working on fairly complex real life project, you already know and use lots of IoC principles. Here's a quick refresher with examples, without much jargon.

Inversion of Control (IoC) is a design principle where control over program flow is shifted to an external framework or container, promoting flexibility and modularity. Note that it is only a principle (or philosophy). Here are some common implementations we see in real world engineering.

Dependecy Injection (DI)

Ever used or defined a parameterized constructor? That's DI in nutshell.
Instead of letting constructor figure out things on its own, you, the caller, pass parameters. Parameters in this case are dependencies, and you as a caller is injecting them. Here's an intuitive example:

class Calculator {
    private int precision;
    Calculator() { // bad example, since no DI here
        this.precision = 2;
    }
    Calculator(int precision) { // good example, since caller can pass precision
        this.precision = precision;
    }

Enter fullscreen mode Exit fullscreen mode

Same reasoning can be applied to any method with parameters.
Ever used @Autowired in Spring? That's also DI. (more details in next section)

Relating to IoC principle: With DI, control is inverted from method to caller. That's IoC.

Inversion of Control Containers

IoC Containers make it easier to do DI in enterprise applications. It takes care of DI and lifecycle management (@PostConstruct and @PreDestroy annotations). Below are 2 code snippets, showing how code looks with and without IoC.

@Repository
class UserRepository { 
    public void save(User user) {
        database System.out.println("User saved: " + user.getName()); 
    } 
}
@Service
class UserService {
    @Autowired private UserRepository repo;
    public void saveUser(User user) {
        this.repo.save(user);
    }
}
public class MainApp {
    @Autowired UserService userService;
    userService.saveUser(new User());
}
Enter fullscreen mode Exit fullscreen mode
class UserRepository { 
    public void save(User user) {
        database System.out.println("User saved: " + user.getName()); 
    } 
}
class UserService {
    private UserRepository repo;
    public UserService(UserRepository repo) {
        this.repo = repo;
    }
    public void saveUser(User user) {
        this.repo.save(user);
    }
}
public class MainApp {
    UserRepository repo = new UserRepository();
    UserService userService = new UserService(repo);
    userService.saveUser(new User());
}
Enter fullscreen mode Exit fullscreen mode

Notice how first code is less verbose since Spring takes care of DI. Now, you might imagine how complex and messy this can get for more complex use cases.

Relating to IoC principle: Control of DI in inverted from programmer to framework.

Template Pattern

Ever used inheritance? That's template pattern.
In this pattern, the control flow of an algorithm is defined in a base class, while specific steps of the algorithm can be implemented in subclasses.

abstract class Beverage {
    public final void prepareBeverage() {
        boilWater();
        brew();
        pourInCup();
    }

    abstract void brew();
    abstract void addCondiments();

    // Common methods with default implementation, can be overridden by subclasses
    void boilWater() { System.out.println("Boiling water"); }
    void pourInCup() { System.out.println("Pouring into cup"); }
}

class Coffee extends Beverage {
    @Override
    void brew() { System.out.println("Dripping Coffee through filter");}
    @Override
    void addCondiments() { System.out.println("Adding sugar and milk");}
    @Override
    void pourInCup() {System.out.println("pouring into takeaway cup"); }
}

class Tea extends Beverage {
    @Override
    void brew() { System.out.println("Steeping the tea"); }
    @Override
    void addCondiments() { System.out.println("Adding lemon"); }
    void sip() { System.out.println("Sipping tea!"); }
}

public class BeverageMaker {

    public static void main(String[] args) {
        Beverage coffee = new Coffee();
        System.out.println("Making coffee...");
        coffee.prepareBeverage();

        System.out.println();

        Beverage tea = new Tea();
        System.out.println("Making tea...");
        tea.prepareBeverage();
    }
}

Enter fullscreen mode Exit fullscreen mode

Relating to IoC principle: Instead of relying on subclasses for implementation, control is given to abstract class to have set methods. This also allows customizations (see Coffee.pourInCup() and extensions (see Tea.sip()).

Factory Pattern

Factories are used to create instances of classes or components. They encapsulate the logic for creating objects, allowing for flexible instantiation based on runtime conditions. Consider following example for better understanding:

interface IStorage { void store(File file); }
class AwsStorage implements IStorage { 
    @Override public void store(File file) {
        System.out.println("Storing " + file.getName() + " in AWS"); 
    } 
}
class LocalStorage implements IStorage { 
    @Override public void store(File file) {
        System.out.println("Storing " + file.getName() + " in Local FS"); 
    } 
}
class FTPStorage implements IStorage { 
    @Override public void store(File file) {
        System.out.println("Storing " + file.getName() + " in FTP"); 
    } 
}

class StorageFactory {
    public static IStorage getStorage(String storageType) {
        if ("AWS".equalsIgnoreCase(storageType)) {
            return new AwsStorage();
        } else if ("LOCAL".equalsIgnoreCase(storageType)) {
            return new LocalStorage();
        } else if ("FTP".equalsIgnoreCase(storageType)) {
            return new FTPStorage();
        }
        return null;
    }
}

public class Main {
    public static void main(String[] args) {
        StorageFactory.getStorage("AWS").store(new File("file.txt"));
        StorageFactory.getStorage("LOCAL").store(new File("file.txt"));
        StorageFactory.getStorage("FTP").store(new File("file.txt"));
    }
}

Enter fullscreen mode Exit fullscreen mode

Notice how client (Main class) can call getStorage() and store() without knowing much about their implementations. getStorage() is also extensible without modifying the client code. This also makes it easier to slice and dice our code to inject mock implementations for testing.

Relating to IoC principle: The control of object creation in inverted from client and delegated to factory component.

Event Handling

This is very common pattern seen across many programming languages. Let's start with and example:
There's a door, and when it opens we want to alert security.

class Door {
    private String name;
    public Door(String name) {
        this.name = name;
    }

    public void open() {
        System.out.println("Door " + name + " is opening.");
        System.out.println("Security system notified: Door " + name + " opened.");
    }
}

public class Main {
    public static void main(String[] args) {
        Door frontDoor = new Door("Front Door");
        frontDoor.open();
    }
}

Enter fullscreen mode Exit fullscreen mode

This works okay, but there's a major problem with it as it grows more complex. Let's say client only wants to alert security based on some condition. Or let's say we want to turn on lights when door is opened. Door class is tightly coupled with security and violates SRP (Single Responsibility Principle). Here's how we can fix it:

class DoorEvent {
    private Door door;
    public DoorEvent(Door door) {
        this.door = door;
    }
    public String getDoor() {
        return door;
    }
}
interface DoorListener {
    void doorOpened(DoorEvent event);
}
class Door {
    private String name;
    private List<DoorListener> listeners;

    public Door(String name) {
        this.name = name;
        this.listeners = new ArrayList<>();
    }

    public void addDoorListener(DoorListener listener) {
        listeners.add(listener);
    }

    public void removeDoorListener(DoorListener listener) {
        listeners.remove(listener);
    }

    public void open() {
        System.out.println("Door " + name + " is opening.");
        // Notify all listeners that the door is opening
        for (DoorListener listener : listeners) {
            listener.doorOpened(new DoorEvent(door));
        }
    }
}

// all the external systems
class SecuritySystem implements DoorListener {
    @Override
    public void doorOpened(DoorEvent event) {
        System.out.println("Security system notified: Door " + event.getDoorName() + " opened.");
    }
}
class LightingSystem implements DoorListener {
    @Override
    public void doorOpened(DoorEvent event) {
        System.out.println("Lights turned on: Door " + event.getDoorName() + " opened.");
    }
}
//client
public class Main {
    public static void main(String[] args) {
        Door frontDoor = new Door("Front Door");

        SecuritySystem securitySystem = new SecuritySystem();
        LightingSystem lightingSystem = new LightingSystem();

        frontDoor.addDoorListener(securitySystem);
        frontDoor.addDoorListener(lightingSystem);

        frontDoor.open();
    }
}
Enter fullscreen mode Exit fullscreen mode

This is better because control is with the caller. Caller don't need to know about interfaces or events at all. This code is also extensible, meaning we can add more implementations of `
DoorListenerwithout modifyingMainorDoor` class.

Relating to IoC principle: Here Door class don't have control over handling DoorEvents. This control is now delegated DoorListener (external component).

Aspect-Oriented Programming (AOP)

AOP aims to separates cross-cutting concerns, such as logging, security, or transaction management, from the core business logic. It can be applied to multiple classes or components without modifying their code directly.

Imagine you are tasked to add log line before and after every function call in some class UserRepository. Here's how you can do it with AOP.

`java
@Aspect
@Component
public class UserRepositoryLoggingAspect {
@Pointcut("execution(* package.repository.UserRepository.*(..))")
private void userRepositoryMethods() {}

@Before("userRepositoryMethods()")
public void logBeforeMethodExecution() {
    System.out.println("Logging before method execution...");
}

@After("userRepositoryMethods()")
public void logAfterMethodExecution() {
    System.out.println("Logging after method execution...");
}
Enter fullscreen mode Exit fullscreen mode

}
`

Notice, how this is accomplished without even looking at code in package.repository.UserRepository.

Relating to IoC principle: Here the control in inverted from UserRepository to UserRepositoryLoggingAspect. The UserRepository class does not directly handle or specify the logging behavior; instead, it is automatically intercepted and augmented with logging functionality by the Spring framework.

Hope this helps, let me know what you think in comments! Checkout my website for more: https://jay.is-savvy.dev/

Top comments (0)