Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 017 / 100

Dependency injection is a pattern too in Design Patterns

DI is just passing dependencies in.

Expona Admin9 min read

Dependency Injection Is a Pattern Too

We talk about design patterns like we're discussing exotic creatures—Factory, Strategy, Observer—but we often overlook one of the most practical and transformative patterns you'll use daily in production code. Dependency injection is a pattern too, and it deserves the same respect and deliberate application as any Gang of Four pattern. Once you see DI as a formal pattern rather than just "passing things in," your entire approach to coupling, testability, and maintainability shifts.

What Makes Dependency Injection a Pattern

At its core, dependency injection (DI) is a pattern for managing object creation and lifetime. Instead of an object creating its own dependencies, those dependencies are provided—injected—from the outside. This sounds simple, but the implications are profound.

The pattern emerged because tight coupling is a real problem. When a class instantiates its own dependencies, it becomes locked into those concrete implementations. You can't swap out a real database client for a mock in tests. You can't experiment with a different logging framework. You're committed.

Dependency injection inverts this control. The class declares what it needs, and something external decides what to provide. This separation is what makes the pattern so powerful, and why it deserves recognition as a legitimate design pattern rather than just a coding habit.

Constructor Injection Makes Dependencies Explicit

The most straightforward form of dependency injection is constructor injection. You declare your dependencies as constructor parameters, and callers pass them in. This approach makes your dependencies immediately visible to anyone reading the code.

Consider a user service in a NestJS application. Without dependency injection, you might write:

typescript
export class UserService {
  private db: Database;
  private logger: Logger;

  constructor() {
    this.db = new PostgresDatabase('production');
    this.logger = new ConsoleLogger();
  }

  async getUser(id: string) {
    this.logger.log(`Fetching user ${id}`);
    return this.db.query('SELECT * FROM users WHERE id = $1', [id]);
  }
}

This class is tightly coupled to PostgresDatabase and ConsoleLogger. Testing is painful—you're always hitting a real database and logging to the console.

With constructor injection, you write:

typescript
export interface IDatabase {
  query(sql: string, params: unknown[]): Promise;
}

export interface ILogger {
  log(message: string): void;
}

export class UserService {
  constructor(
    private db: IDatabase,
    private logger: ILogger
  ) {}

  async getUser(id: string) {
    this.logger.log(`Fetching user ${id}`);
    return this.db.query('SELECT * FROM users WHERE id = $1', [id]);
  }
}

Now the dependencies are explicit. Anyone looking at the constructor immediately knows what UserService needs. The class depends on abstractions (interfaces), not concrete implementations. This is the essence of the Dependency Inversion Principle, and it's only possible through dependency injection.

You also gain flexibility. The same UserService can work with any IDatabase implementation—PostgreSQL, MongoDB, SQLite, or a mock:

typescript
class MockDatabase implements IDatabase {
  query(sql: string, params: unknown[]): Promise {
    return Promise.resolve({ id: 'test-1', name: 'Test User' });
  }
}

const mockDb = new MockDatabase();
const mockLogger = { log: () => {} };
const userService = new UserService(mockDb, mockLogger);

// Now testing is straightforward
const user = await userService.getUser('test-1');

Constructor injection forces explicitness. You can't hide dependencies. You can't accidentally create a new instance somewhere deep in your code. Everything flows through the constructor, making the dependency graph clear and testable.

Containers Automate the Wiring

Constructor injection is powerful, but managing object creation becomes tedious as your application grows. A UserService needs a Database. The Database needs a connection pool. The connection pool needs configuration. Each component needs configuration loaded from environment variables. Where do you create all these objects?

This is where dependency injection containers enter the picture. A DI container is a registry and factory for your objects. You tell it how to create each dependency, and it handles wiring them together—transitively, recursively, as complex as needed.

In NestJS, the container is built in. You use decorators to declare what's injectable:

typescript
@Injectable()
export class DatabaseService {
  constructor(private config: ConfigService) {
    // Container provides ConfigService automatically
  }
}

@Injectable()
export class UserService {
  constructor(private db: DatabaseService) {
    // Container provides DatabaseService, which itself depends on ConfigService
  }
}

@Controller('users')
export class UserController {
  constructor(private userService: UserService) {
    // Container resolves the entire dependency graph
  }
}

The container sees that UserController needs UserService, which needs DatabaseService, which needs ConfigService. It constructs ConfigService first, passes it to DatabaseService, then passes that to UserService, then to UserController. This all happens automatically.

In a .NET context, you'd use a tool like Autofac or the built-in Microsoft.Extensions.DependencyInjection:

typescript
// Pseudo-code for .NET container setup
var builder = new ContainerBuilder();
builder.RegisterType<PostgresDatabase>().As<IDatabase>();
builder.RegisterType<ConsoleLogger>().As<ILogger>();
builder.RegisterType<UserService>();

var container = builder.Build();
var userService = container.Resolve<UserService>();
// Container recursively resolves all dependencies

Containers solve a real problem: as your application grows from 5 classes to 50 to 500, manually wiring dependencies becomes a bottleneck. The container automates this, following your declarations.

The container also handles lifecycle management. Should a service be a singleton (one instance for the whole application) or transient (new instance every time)? Should it be scoped to a request? These decisions are typically declared once at registration time, then enforced by the container throughout your application's lifetime.

Easy Substitution in Tests

Testing is where dependency injection shines. Because your code depends on abstractions and receives those abstractions via constructor, swapping implementations for tests is trivial.

Say you're testing a PaymentService that charges users and sends them a receipt via email:

typescript
export interface IPaymentGateway {
  charge(amount: number, token: string): Promise<{ transactionId: string }>;
}

export interface IEmailService {
  send(to: string, subject: string, body: string): Promise;
}

export class PaymentService {
  constructor(
    private gateway: IPaymentGateway,
    private email: IEmailService
  ) {}

  async processPayment(userId: string, amount: number, token: string) {
    const result = await this.gateway.charge(amount, token);
    await this.email.send(`user-${userId}@example.com`, 'Receipt', `Charged $${amount}`);
    return result;
  }
}

In production, you'd inject real implementations. In tests, you inject mocks:

typescript
describe('PaymentService', () => {
  let paymentService: PaymentService;
  let mockGateway: IPaymentGateway;
  let mockEmail: IEmailService;

  beforeEach(() => {
    mockGateway = {
      charge: jest.fn().mockResolvedValue({ transactionId: 'txn-123' })
    };
    mockEmail = {
      send: jest.fn().mockResolvedValue(undefined)
    };
    paymentService = new PaymentService(mockGateway, mockEmail);
  });

  it('should charge and send email', async () => {
    await paymentService.processPayment('user-1', 100, 'token-abc');
    expect(mockGateway.charge).toHaveBeenCalledWith(100, 'token-abc');
    expect(mockEmail.send).toHaveBeenCalledWith(
      'user-1@example.com',
      'Receipt',
      expect.any(String)
    );
  });
});

You're not stubbing methods on a real payment gateway or trying to intercept email. You're passing in objects that implement the right interfaces and behave exactly as your test needs. This is fast, reliable, and doesn't require test databases or external service mocking libraries.

Dependency injection is what makes unit testing feasible at scale. Without it, your tests become integration tests by necessity—they have to spin up real databases, mock external HTTP calls, manage fixtures. With DI, your tests are genuinely isolated and fast.

No Container Needed for Small Apps

Here's an important nuance: you don't need a container to practice dependency injection. Containers are an optimization for complexity, not a requirement for the pattern itself.

I've seen small applications where the entire dependency graph is wired by hand in a main.ts or Program.cs file:

typescript
// Small app—no container needed
const config = new ConfigService();
const db = new PostgresDatabase(config);
const logger = new ConsoleLogger();
const userService = new UserService(db, logger);
const userController = new UserController(userService);

const app = express();
app.get('/users/:id', (req, res) => {
  userController.getUser(req, res);
});

This is still dependency injection. The dependencies are explicit, passed through constructors, and easy to swap for tests. There's no magic, no reflection, no container scanning your codebase.

For a 3-service microservice or a small CLI tool, this is perfectly fine. You see exactly where everything is created, in one place. The moment you're creating 20+ objects with complex interdependencies, a container becomes worth the extra conceptual overhead. But the pattern itself is independent of the container.

I've also seen the opposite mistake: teams using a sophisticated DI container for a 2-class application, adding complexity they don't need. Start with constructor injection and manual wiring. When it becomes unwieldy, introduce a container. Don't cargo-cult patterns based on what you see in large frameworks.

Common Mistakes

Injecting containers instead of dependencies: Some teams inject the DI container itself into services, then have services resolve their own dependencies from the container. This defeats the purpose—you've hidden your dependencies and made testing harder. Inject the actual dependencies, not the container.

Creating "God" services: When constructor injection gets verbose because a service needs 10 dependencies, that's not a sign to use property injection or to instantiate things internally. It's a sign the service is doing too much. Break it apart into smaller, focused services.

Mixing injection strategies: Use one injection method consistently. If you use constructor injection for most dependencies but property injection for others, your code becomes unpredictable. Constructor injection is the standard for good reasons—it's explicit and makes immutability easier.

Over-abstracting: Not everything needs an interface. If a dependency is unlikely to have multiple implementations or be mocked, a concrete class is fine. Interfaces add value when you need polymorphism or testability, not everywhere.

Key Takeaways

  • Dependency injection is a formal pattern for managing dependencies and controlling coupling, not just a coding habit.
  • Constructor injection makes dependencies explicit and prevents hidden coupling.
  • Depending on abstractions (interfaces) rather than concrete implementations enables substitution and flexibility.
  • DI containers automate the wiring of complex dependency graphs, but they're optional for small applications.
  • Dependency injection is what makes unit testing practical—you can substitute real implementations with mocks trivially.
  • Start with manual constructor injection and introduce a container only when the boilerplate becomes genuinely painful.
  • Common mistakes include injecting the container itself, creating services with too many dependencies, and mixing injection strategies.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles