Using Dependency Injection in an Express API with TypeScript

Published on: March 14, 2025

If you’re building an Express API with TypeScript, you might have wondered how to keep your code clean, testable, and easy to maintain.

One way to achieve that is by using Dependency Injection (DI) (Have you heard about the SOLID principles?).

It’s a design pattern that helps you pass dependencies into your classes or functions instead of hardcoding them. Sounds cool, right? Let’s explore if it’s possible in an Express setup (spoiler: it's totally possible!) and walk through a practical example.

Why Dependency Injection in Express?

Express is awesome for quickly spinning up APIs, but as your app grows, you might notice tight coupling creeping in.

For example, your controllers might directly create database connections or call services, making unit testing a nightmare.

DI flips that around by letting you “inject” those dependencies, so you can swap them out (say, for mocks) when needed.

Plus, it makes your code more modular, scalable, and easier to maintain.

Setting the Scene

Imagine you’re building a simple API to manage a to-do list.

You’ve got a controller to handle HTTP requests, a service to handle business logic, and a repository to talk to the database.

Without DI, your controller might look like this:

import { Request, Response } from "express";
import { TodoService } from "./todoService";

class TodoController {
  private todoService = new TodoService(); // <== Hardcoded dependency ❌

  async getTodos(req: Request, res: Response) {
    const todos = await this.todoService.fetchTodos();
    res.json(todos);
  }
}

The problem? TodoController is tightly coupled with TodoService. If you want to test it or swap the service later, you’re in for some refactoring.

Let’s apply DI step by step.

Step 1: Define Interfaces

First, let’s create an interface for our service.

This keeps things flexible, so we can swap implementations without breaking the controller.

interface ITodoService {
  fetchTodos(): Promise<string[]>;
}

Now, let’s implement it in a concrete class.

class TodoService implements ITodoService {
  async fetchTodos() {
    // Imagine this talks to a database a returns the array
    return ["Buy groceries", "Walk the dog"];
  }
}

Step 2: Inject the Dependency

Instead of hardcoding the service, we’ll pass it into the controller’s constructor.

Here’s the updated TodoController:

import { Request, Response } from "express";

class TodoController {
  private todoService: ITodoService;

  constructor(todoService: ITodoService) {
    this.todoService = todoService;
  }

  async getTodos(req: Request, res: Response) {
    const todos = await this.todoService.fetchTodos();
    res.json(todos);
  }
}

Now, TodoController doesn’t care where todoService comes from. It just needs something that follows ITodoService.

This is the heart of DI!

Step 3: Wire It Up in Express

Time to connect this to Express. We’ll create an instance of TodoService and inject it into TodoController.

Here’s how you might set up your app:

import express from "express";
import { TodoController } from "./todoController";
import { TodoService } from "./todoService";

const app = express();
const todoService = new TodoService();
const todoController = new TodoController(todoService);

app.get("/todos", (req, res) => todoController.getTodos(req, res));

app.listen(3000, () => {
  console.log("Server running on port 3000");
});

When a GET request hits /todos, the controller uses the injected service to fetch the data.

Step 4: Taking It Further with a DI Container

Manually creating instances works for small apps, but for bigger projects, a DI container can automate this.

Let’s use tsyringe, a lightweight DI library for TypeScript. First, install it:

npm install tsyringe

Now, update your service and controller to work with tsyringe.

Mark them as injectable and register them with the container:

import { injectable, inject } from "tsyringe";
import { Request, Response } from "express";

@injectable()
class TodoService implements ITodoService {
  async fetchTodos() {
    return ["Buy groceries", "Walk the dog"];
  }
}

@injectable()
class TodoController {
  constructor(@inject("ITodoService") private todoService: ITodoService) {}

  async getTodos(req: Request, res: Response) {
    const todos = await this.todoService.fetchTodos();
    res.json(todos);
  }
}

In your main file, set up the container and resolve the controller:

import express from "express";
import { container } from "tsyringe";
import { TodoController } from "./todoController";
import { TodoService } from "./todoService";

container.register("ITodoService", {
  useClass: TodoService,
});

const app = express();
const todoController = container.resolve(TodoController);

app.get("/todos", (req, res) => todoController.getTodos(req, res));

app.listen(3000, () => {
  console.log("Server running on port 3000");
});

Here, tsyringe handles creating and injecting TodoService into TodoController.

If you ever need a different implementation (say, MockTodoService for testing), just update the registration.

Bonus: Scoped vs Singleton Services

By default, tsyringe creates new instances per request, but you can configure it to reuse instances (singleton) if needed:

container.registerSingleton("ITodoService", TodoService);

Use singleton for shared resources like database connections, but keep scoped or transient instances for services handling per-request logic.

Using DI in Express Middleware

You can also apply DI to Express middleware. Injecting an AuthService is a common use case:

@injectable()
class AuthMiddleware {
  constructor(@inject("AuthService") private authService: AuthService) {}

  handle(req: Request, res: Response, next: NextFunction) {
    if (!this.authService.isAuthenticated(req)) {
      return res.status(401).json({ error: "Unauthorized" });
    }
    next();
  }
}

Then, resolve it in your Express app:

const authMiddleware = container.resolve(AuthMiddleware);
app.use(authMiddleware.handle);

Real-Life Bonus: Testing Made Easy

With DI, testing is easy. Here’s a quick example using Jest:

class MockTodoService implements ITodoService {
  async fetchTodos() {
    return ["Test todo"];
  }
}

test("TodoController returns todos", async () => {
  const mockService = new MockTodoService();
  const controller = new TodoController(mockService);
  const req = {} as Request;
  const res = { json: jest.fn() } as unknown as Response;

  await controller.getTodos(req, res);
  expect(res.json).toHaveBeenCalledWith(["Test todo"]);
});

No database? No worries, just mock a service and inject it into the controller.

Wrapping Up

Dependency Injection is totally possible in an Express API with TypeScript!

Whether you go manual with constructor injection or fancy with a container like tsyringe, it’s all about keeping your code flexible, testable, and scalable.

This approach works not just for to-do lists, but also for user authentication, payments, and even microservices architectures.

Next time you’re building an API, give DI a shot. It might just save you a headache down the road.


Happy coding! ⚡


Dependency InjectionOOPSOLID PrinciplesExpressNodejsTypeScript