Skip to main content

Express Dependency Injection

Dependency injection is a design pattern that helps you write more maintainable, testable, and modular code by reducing tight coupling between components. In this tutorial, we'll explore how to implement dependency injection in Express.js applications and why it's beneficial for your project architecture.

Introduction to Dependency Injection

Dependency injection (DI) is a technique where an object receives other objects it depends on, rather than creating them internally. This pattern shifts the responsibility of providing dependencies to external code.

In simple terms:

  • Without DI: Components create or directly reference their dependencies
  • With DI: Dependencies are "injected" from outside

For Express applications, implementing DI can help you:

  • Create more testable code with easy mocking
  • Improve separation of concerns
  • Make your application more maintainable as it grows
  • Simplify switching implementations (like databases or services)

Basic Dependency Injection in Express

Let's start with a simple example of how dependency injection works in Express.

The Traditional Approach (Without DI)

Here's how you might typically write a route handler:

javascript
// userRoutes.js
const express = require('express');
const router = express.Router();
const UserModel = require('../models/userModel');

router.get('/', async (req, res) => {
try {
const users = await UserModel.find();
res.json(users);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch users' });
}
});

module.exports = router;

In this example, the route handler directly depends on UserModel. This creates tight coupling and makes testing difficult.

The Dependency Injection Approach

Now let's refactor this using dependency injection:

javascript
// userRoutes.js
const express = require('express');

// The router factory accepts dependencies
function createUserRouter(userService) {
const router = express.Router();

router.get('/', async (req, res) => {
try {
const users = await userService.getAllUsers();
res.json(users);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch users' });
}
});

return router;
}

module.exports = createUserRouter;
javascript
// app.js
const express = require('express');
const createUserRouter = require('./routes/userRoutes');
const UserService = require('./services/userService');

const app = express();

// Create the service
const userService = new UserService();

// Inject the service when creating the router
app.use('/users', createUserRouter(userService));

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

In this approach, the router doesn't create its dependencies. Instead, they're passed in from the outside, making the code more flexible and testable.

Implementing a Service Layer

A common pattern with dependency injection is to implement a service layer that encapsulates business logic and data access.

javascript
// userService.js
class UserService {
constructor(userRepository) {
this.userRepository = userRepository;
}

async getAllUsers() {
return this.userRepository.findAll();
}

async getUserById(id) {
return this.userRepository.findById(id);
}

async createUser(userData) {
// Validate user data
if (!userData.email || !userData.name) {
throw new Error('User must have email and name');
}

return this.userRepository.create(userData);
}
}

module.exports = UserService;
javascript
// userRepository.js
class UserRepository {
constructor(db) {
this.db = db;
}

async findAll() {
return this.db.collection('users').find().toArray();
}

async findById(id) {
return this.db.collection('users').findOne({ _id: id });
}

async create(userData) {
const result = await this.db.collection('users').insertOne(userData);
return { ...userData, _id: result.insertedId };
}
}

module.exports = UserRepository;

Creating a DI Container

For more complex applications, manually wiring up all dependencies can become cumbersome. A dependency injection container can help manage these relationships.

Let's implement a simple DI container:

javascript
// container.js
class DIContainer {
constructor() {
this.services = {};
}

register(name, definition, dependencies) {
this.services[name] = { definition, dependencies };
}

get(name) {
const service = this.services[name];
if (!service) {
throw new Error(`Service ${name} not found`);
}

if (typeof service.definition === 'function') {
const dependencies = service.dependencies.map(dep => this.get(dep));
return service.definition(...dependencies);
} else {
return service.definition;
}
}
}

module.exports = DIContainer;

Now let's see how to use this container in our application:

javascript
// setup.js
const DIContainer = require('./container');
const UserRepository = require('./repositories/userRepository');
const UserService = require('./services/userService');
const createUserRouter = require('./routes/userRoutes');
const mongodb = require('mongodb');

async function setupContainer() {
// Create the container
const container = new DIContainer();

// Connect to MongoDB
const client = await mongodb.MongoClient.connect('mongodb://localhost:27017');
const db = client.db('myapp');

// Register services
container.register('db', db, []);
container.register('userRepository',
db => new UserRepository(db), ['db']);
container.register('userService',
userRepo => new UserService(userRepo), ['userRepository']);
container.register('userRouter',
userService => createUserRouter(userService), ['userService']);

return container;
}

module.exports = setupContainer;
javascript
// app.js
const express = require('express');
const setupContainer = require('./setup');

async function startApp() {
const app = express();
const container = await setupContainer();

// Use the router from the container
app.use('/users', container.get('userRouter'));

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

startApp().catch(console.error);

Testing with Dependency Injection

One of the biggest advantages of dependency injection is improved testability. Let's see how to write a test for our user routes:

javascript
// userRoutes.test.js
const request = require('supertest');
const express = require('express');
const createUserRouter = require('../routes/userRoutes');

describe('User Routes', () => {
test('GET / returns all users', async () => {
// Create a mock user service
const mockUserService = {
getAllUsers: jest.fn().mockResolvedValue([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
])
};

// Create the Express app with injected mock
const app = express();
app.use(express.json());
app.use('/users', createUserRouter(mockUserService));

// Test the endpoint
const response = await request(app).get('/users');

expect(response.status).toBe(200);
expect(response.body.length).toBe(2);
expect(mockUserService.getAllUsers).toHaveBeenCalled();
});
});

Real-World Example: Complete Express Application

Let's bring everything together in a more complete example:

javascript
// src/app.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const { createContainer } = require('./container');

async function createApp() {
// Initialize the app
const app = express();

// Set up middleware
app.use(helmet());
app.use(cors());
app.use(express.json());
app.use(morgan('dev'));

// Set up the DI container
const container = await createContainer();

// Register routes with injected dependencies
app.use('/api/users', container.get('userRouter'));
app.use('/api/products', container.get('productRouter'));
app.use('/api/orders', container.get('orderRouter'));

// Error handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
error: 'Something went wrong!',
message: err.message
});
});

return app;
}

module.exports = { createApp };
javascript
// src/server.js
const { createApp } = require('./app');

async function startServer() {
const app = await createApp();
const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
}

startServer().catch(err => {
console.error('Failed to start server:', err);
process.exit(1);
});

This structure allows us to:

  1. Create the app separately from starting the server
  2. Inject all dependencies in a controlled manner
  3. Easily test components in isolation

While we've built a simple DI container, there are several mature libraries you can use:

  1. Awilix - Extremely powerful and intuitive DI container
  2. InversifyJS - A powerful IoC container that supports TypeScript
  3. TypeDI - A dependency injection tool that works with TypeScript
  4. Bottlejs - A powerful dependency injection micro container

Here's an example using Awilix:

javascript
const awilix = require('awilix');
const { createContainer, asClass, asValue, asFunction } = awilix;

// Create the DI container
const container = createContainer();

// Register services
container.register({
// Values
db: asValue(db),

// Classes
userRepository: asClass(UserRepository).singleton(),
userService: asClass(UserService).singleton(),

// Functions
userRouter: asFunction(createUserRouter).singleton()
});

// Resolve dependencies
const userRouter = container.resolve('userRouter');
app.use('/users', userRouter);

Best Practices for Dependency Injection in Express

  1. Keep services stateless when possible: This makes them easier to share and reason about
  2. Use interfaces/abstractions: Program to interfaces, not implementations
  3. Single Responsibility Principle: Each service should have one responsibility
  4. Avoid circular dependencies: They make your application harder to understand
  5. Use a DI container for complex apps: As your app grows, manual wiring becomes tedious
  6. Consider using TypeScript: Static typing helps catch issues with dependencies early

Summary

Dependency injection is a powerful pattern that can improve the structure, testability, and maintainability of your Express applications. By decoupling components and explicitly declaring their dependencies, you create code that is more modular and easier to change over time.

Key benefits we've covered:

  • Improved testability with easy mocking of dependencies
  • Better separation of concerns
  • More maintainable and flexible code architecture
  • Support for different implementations based on context

As your applications grow in complexity, investing in proper dependency injection patterns will pay dividends in code quality and developer productivity.

Further Resources

  1. Awilix Documentation
  2. InversifyJS Documentation
  3. SOLID Principles in JavaScript
  4. Martin Fowler on Dependency Injection

Exercises

  1. Refactor a simple Express route to use dependency injection
  2. Implement a basic service and repository layer for an Express API
  3. Write tests for an Express route using mocked dependencies
  4. Set up a complete Express application using a dependency injection container
  5. Compare different dependency injection libraries and select one for your project

Happy coding!



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)