Skip to content

Why Use Dependency Injection?

Introduction

In this guide, we will explore how Dependency Injection (DI) can address various challenges in software development. We will discuss the advantages of using @wroud/di and identify scenarios where DI is particularly beneficial. Through step-by-step examples, we will demonstrate the practical benefits and efficiency gains achieved by implementing DI in your applications. Learn how DI can improve your code structure, enhance testability, and manage dependencies more effectively.

Example 1: Base Application

We'll start with a simple example to understand the structure and then evolve it step-by-step.

Initial Code

typescript
class Logger {
  log(message: string) {
    console.log(message);
  }
}

class Database {
  constructor(private logger: Logger) {}
  connect() {
    this.logger.log("connected");
  }
  disconnect() {
    this.logger.log("disconnected");
  }
}

class App {
  constructor(
    private logger: Logger,
    private database: Database,
  ) {}
  start() {
    this.database.connect();
    this.logger.log("started");
  }
  stop() {
    this.database.disconnect();
    this.logger.log("stopped");
  }
}

In this basic example, we have an App class that depends on a Logger and a Database. The Database also depends on the Logger. This setup shows how the App starts and stops by connecting and disconnecting the database and logging these events.

typescript
const logger = new Logger();
const database = new Database(logger);
const app = new App(logger, database);
app.start();
app.stop();

In this initialization variant, we manually create instances of Logger and Database and pass them to the App constructor. This approach works but can become cumbersome as the application grows.

Example 2: Expanding the Base Application

Now, let's expand our base application by adding more classes and see how the initialization changes.

Expanded Application Code

typescript
class Logger {
  log(message: string) {
    console.log(message);
  }
}

class Database {
  constructor(private logger: Logger) {}
  connect() {
    this.logger.log("connected");
  }
  disconnect() {
    this.logger.log("disconnected");
  }
  query() {
    this.logger.log("queried");
  }
}

class UsersManager {
  constructor(
    private logger: Logger,
    private database: Database,
  ) {}
  addUser() {
    this.database.query();
    this.logger.log("added user");
  }
}

class RegistrationService {
  constructor(
    private logger: Logger,
    private usersManager: UsersManager,
  ) {}
  registerUser() {
    this.usersManager.addUser();
    this.logger.log("registered user");
  }
}

class App {
  constructor(
    private logger: Logger,
    private database: Database,
  ) {}
  start() {
    this.database.connect();
    this.logger.log("started");
  }
  stop() {
    this.database.disconnect();
    this.logger.log("stopped");
  }
}

In this expanded version, we added UsersManager and RegistrationService classes. These new classes also depend on Logger and Database.

typescript
const logger = new Logger();
const database = new Database(logger);
const usersManager = new UsersManager(logger, database);
const registrationService = new RegistrationService(logger, usersManager);
const app = new App(logger, database);
app.start();
registrationService.registerUser();
app.stop();

This variant demonstrates the manual creation of instances for all new classes. As the application grows, this approach quickly becomes tedious and error-prone due to the manual wiring of dependencies.

Example 3: Separating Concerns with Independent Services

Next, we will further refactor our application by separating some functionality into independent services. This helps in managing responsibilities better.

Refactored Application Code

typescript
class Logger {
  log(message: string) {
    console.log(message);
  }
}

class DatabaseConnection {
  constructor(private logger: Logger) {}
  rawQuery() {
    this.logger.log("raw queried");
  }
  connect() {
    this.logger.log("connected");
  }
  disconnect() {
    this.logger.log("disconnected");
  }
}

class Database {
  constructor(
    private logger: Logger,
    private connection: DatabaseConnection,
  ) {}
  query() {
    this.connection.rawQuery();
    this.logger.log("queried");
  }
  disconnect() {
    this.logger.log("disconnected");
  }
  query() {
    this.logger.log("queried");
  }
}

class UsersManager {
  constructor(
    private logger: Logger,
    private database: Database,
  ) {}
  addUser() {
    this.database.query();
    this.logger.log("added user");
  }
}

class RegistrationService {
  constructor(
    private logger: Logger,
    private usersManager: UsersManager,
  ) {}
  registerUser() {
    this.usersManager.addUser();
    this.logger.log("registered user");
  }
}

class App {
  constructor(
    private logger: Logger,
    private database: Database,
    private connection: DatabaseConnection,
  ) {}
  start() {
    this.database.connect();
    this.connection.connect();
    this.logger.log("started");
  }
  stop() {
    this.database.disconnect();
    this.connection.disconnect();
    this.logger.log("stopped");
  }
}

In this refactoring, we introduced a new DatabaseConnection class to handle the actual connection logic. This separation of concerns makes the code more modular and easier to manage.

typescript
const logger = new Logger();
const databaseConnection = new DatabaseConnection(logger);
const database = new Database(logger);
const database = new Database(logger, databaseConnection);
const usersManager = new UsersManager(logger, database);
const registrationService = new RegistrationService(logger, usersManager);
const app = new App(logger, database);
const app = new App(logger, databaseConnection);
app.start();
registrationService.registerUser();
app.stop();

This variant shows the manual creation and wiring of all instances, including the new DatabaseConnection class. As expected, this can get complex and error-prone as the number of dependencies grows.

Explanation

  1. Defining Services: Each class is decorated with @injectable(), making them injectable services.
  2. Service Container Builder: We create a ServiceContainerBuilder and register each service with addSingleton.
  3. Resolving Dependencies: The IServiceProvider automatically handles the creation and injection of dependencies.
  4. Starting the Application: The App class is resolved from the container, and its dependencies are injected automatically.

Benefits of Using Dependency Injection

  • Simplicity: Dependencies are declared and managed in one place, reducing boilerplate code.
  • Flexibility: Easily swap implementations of services without changing the dependent code.
  • Testability: Mock dependencies can be injected for testing purposes, improving testability.
  • Maintainability: As the project grows, managing dependencies remains straightforward and less error-prone.

Summary

Through these examples, we've illustrated the progression from manual dependency management to using a dependency injection system. By leveraging @wroud/di, we achieve a cleaner, more maintainable, and flexible codebase. Dependency Injection simplifies the initialization and wiring of services, allowing developers to focus on the core logic of their applications.

Released under the MIT License.