Dependency Inversion - A Crucial Design Principle for Software Architects

Cuong Vo Manh · June 1, 2023

As a software architect, one of the most important decisions you’ll make is how to design the components of your system to be as flexible and maintainable as possible. One way to achieve this is by utilizing the Dependency Inversion Principle (DIP), which states that:

  • High-level modules should not depend on low-level modules. Both should depend on abstractions.
  • Abstractions should not depend on details. Details should depend on abstractions.

In other words, DIP suggests that rather than building a system with hard-coded dependencies between its components, you should instead specify interfaces (abstractions) that define how the different components can interact with each other. This creates a more flexible architecture, where changes to one component do not necessarily require changes to any other component.

Here are some code examples to illustrate how software architects can use DIP to make important design decisions:

  1. Choosing Appropriate Design Patterns

One way to implement DIP in your architecture is by using design patterns such as the Factory Method, Abstract Factory, or Strategy patterns.

Take the Factory Method pattern for example:

public interface Product {
    // Define product interface
}

public class ConcreteProductA implements Product {
    // Provide a concrete implementation of Product
}

public class ConcreteProductB implements Product {
    // Provide another concrete implementation of Product
}

public interface ProductFactory {
    Product createProduct();
}

public class ConcreteProductFactoryA implements ProductFactory {
    @Override
    public Product createProduct() {
        return new ConcreteProductA();
    }
}

public class ConcreteProductFactoryB implements ProductFactory {
    @Override
    public Product createProduct() {
        return new ConcreteProductB();
    }
}

In this example, the implementation of the Product interface is provided by two concrete classes, ConcreteProductA and ConcreteProductB, while the ProductFactory interface provides the createProduct() method. This allows clients of the Product interface to use any implementation interchangeably at runtime, allowing for a flexible architecture.

  1. Separating Concerns

Another important decision for software architects is how to separate different concerns within their system. DIP can help with this by specifying that each module should only depend on abstractions rather than concrete implementations.

public class Database {
    public void connect(String connectionString) {
        // Connect to database
    }

    public void disconnect() {
        // Disconnect from database
    }

    public void executeQuery(String query) {
        // Execute query
    }
}

public interface QueryExecutor {
    void executeQuery(String query);
}

public class DatabaseQueryExecutor implements QueryExecutor {
    private final Database database;

    public DatabaseQueryExecutor(Database database) {
        this.database = database;
    }

    @Override
    public void executeQuery(String query) {
        database.connect("jdbc:mysql://localhost/myDatabase");
        database.executeQuery(query);
        database.disconnect();
    }
}

In this example, we have a Database class that handles the logic for connecting to a database, executing a query, and disconnecting from the database. We then create an interface, QueryExecutor, that defines only the executeQuery() method.

We then create a DatabaseQueryExecutor class that implements the QueryExecutor interface, and takes a Database instance in its constructor. This illustrates the separation of concerns between the Database class and the QueryExecutor interface.

  1. Scaling Your Architecture

As your architecture grows and becomes more complex, DIP becomes even more important to keep your architecture flexible and maintainable.

public interface IEntity {
    int getId();
}

public class Customer implements IEntity {
    private final int id;
    private final String name;

    public Customer(int id, String name) {
        this.id = id;
        this.name = name;
    }

    @Override
    public int getId() {
        return id;
    }

    public String getName() {
        return name;
    }
}

public interface IRepository<T extends IEntity> {
    void add(T entity);

    void remove(T entity);

    T getById(int id);
}

public class CustomerRepository implements IRepository<Customer> {
    private final Map<Integer, Customer> customers = new HashMap<>();

    @Override
    public void add(Customer entity) {
        customers.put(entity.getId(), entity);
    }

    @Override
    public void remove(Customer entity) {
        customers.remove(entity.getId());
    }

    @Override
    public Customer getById(int id) {
        return customers.get(id);
    }
}

In this example, we have an IEntity interface that defines only the getId() method, and a Customer class that implements this interface. We also have an IRepository interface that uses the IEntity interface as its type parameter.

We then have a CustomerRepository class that implements the IRepository interface using the Customer class as its type parameter. This provides a flexible architecture that scales as more entities are added to the system.

In conclusion, the Dependency Inversion Principle is a crucial design principle for software architects. By using appropriate design patterns, separating concerns, and scaling your architecture with DIP in mind, you can build a more flexible and maintainable system that scales with your business needs.

Twitter, Facebook