DEV Community

Cover image for Dependency Injection Explained: Why Your Classes Shouldn't Know How Everything Is Built
Nsikan Patrick Adaowo
Nsikan Patrick Adaowo

Posted on

Dependency Injection Explained: Why Your Classes Shouldn't Know How Everything Is Built

Your codebase may not be failing because it lacks features. It may be failing because everything knows too much about everything else.

Most backend developers learn dependency injection through a framework.

In NestJS, you add decorators. In Spring, you annotate classes. In Angular, you declare providers. The framework then constructs your objects and passes their dependencies.

Everything works fine.

But many developers stop at the mechanics.

They learn how to use dependency injection, but not why it matters.

They learn the syntax, but not the design principle.

They learn the pattern, but not the problem it solves.

The result is code that uses dependency injection but still feels tightly coupled, difficult to test, and fragile when requirements change.

This article explains what dependency injection really is, why your classes should not know how their dependencies are built, and how to use it in a way that improves maintainability, testability, and flexibility.


The problem: classes that build their own world
Consider this service:

This code may work.

But it has a hidden problem.

The OrderService knows:

  • Which database library to use.
  • Which payment provider to call.
  • Which email client to instantiate.
  • How to construct each dependency.

This creates tight coupling.

If you want to:

  • Replace Paystack with Flutterwave.
  • Switch from Prisma to TypeORM.
  • Use a different email provider.
  • Test the service without calling real APIs.
  • Use a different configuration in staging versus production.

you must modify the service itself.
The class is responsible not only for its business logic, but also for constructing its entire world.
This is the opposite of what dependency injection tries to achieve.


What dependency injection actually is

Dependency injection is a design pattern where a class receives its dependencies from the outside instead of creating them internally.
Instead of:

You write:

The dependency is provided by an external assembler, such as:

  • A framework container.
  • A factory function.
  • A manual composition root.
  • A dependency injection library.

The class no longer decides how its dependencies are built.
It only declares what it needs.


Inversion of Control: the bigger idea
Dependency injection is a specific way to implement a broader principle called Inversion of Control.
In traditional code, a high-level component directly creates or locates its low-level dependencies.
For example:

The ReportGenerator controls how the database is obtained.
Under Inversion of Control, this responsibility is inverted.
An external mechanism provides the dependency:

Now the framework or container controls object creation and dependency resolution.
The high-level component declares what it needs but does not control how it is constructed.
This inversion of control leads to looser coupling and greater flexibility.


Constructor injection: the most common form

The most widely used form of dependency injection is constructor injection.
Dependencies are passed through the constructor:

This approach has several advantages:

The object is fully initialized when created.
Dependencies are clearly visible in the constructor signature.
Fields can be immutable.
It fails fast if a dependency is missing.

A class that requires certain dependencies should not be constructible without them.
Constructor injection enforces this requirement.


Setter and interface injection

Other forms of dependency injection exist, though they are less common in modern backend code.
Setter injection
Dependencies are provided through setter methods after construction:
This can be useful when dependencies are optional or change over time, but it also allows objects to exist in a partially initialized state.
Interface injection
A class implements an interface that defines how dependencies are injected.
This is less common in TypeScript and JavaScript ecosystems.
For most backend applications, constructor injection is the simplest and most reliable approach.


A practical NestJS example

NestJS provides a built-in dependency injection container.
A service can declare its dependencies in the constructor:
NestJS resolves the dependencies when the service is instantiated.
The service does not need to know:

Whether OrderRepository uses Prisma, TypeORM, or a raw SQL client.
Whether PaymentGateway is implemented with Paystack, Stripe, or a mock.
How NotificationService sends messages.

It only knows the capabilities it requires.
This separation makes the service easier to test and easier to evolve.


Why dependency injection matters

1. Reduced coupling
When a class constructs its own dependencies, it is tightly coupled to those implementations.
Dependency injection separates the use of a dependency from its construction.
The class depends on an abstraction or interface, not on a specific concrete class.
This makes it easier to switch implementations without modifying the consumer.
For example, you can replace a payment gateway implementation without changing the order service.
2. Improved testability
Testing becomes easier when dependencies can be replaced with fakes or mocks.
Consider this test:

The test can verify:

  • The correct amount is charged.
  • The order is created with the right data.
  • The notification is sent.
  • Error handling behaves correctly.

No real database, payment provider, or email server is required.
This makes tests faster, more reliable, and easier to run in isolation.
3. Easier configuration management
Different environments often require different configurations.
For example:

Development may use local services.
Staging may use test providers.
Production may use real providers.

With dependency injection, you can compose different implementations for different environments without changing the core business logic.
4. Clearer responsibilities
When a class receives its dependencies, its constructor signature communicates what it needs.
This makes the code easier to understand.
A developer can look at the constructor and immediately see the external capabilities the class depends on.
This is more explicit than hidden instantiations scattered throughout methods.
5. Better separation of concerns
Dependency injection helps separate:

  • Business logic.
  • Infrastructure details.
  • Object construction.
  • Configuration.

The business logic focuses on what needs to happen.
The infrastructure layer focuses on how to interact with external systems.
The composition root or container focuses on how to assemble everything.
This separation makes the system easier to reason about and easier to change.


Dependency injection versus service locator

A service locator is another pattern for obtaining dependencies.
Instead of injecting dependencies, a class asks a central registry for them:

This may look convenient, but it has important drawbacks:
Dependencies are hidden.
The class can request anything from the locator.
It is harder to see what a class truly needs.
Tests must configure the global locator.
It can encourage tight coupling to the locator itself.

Dependency injection makes dependencies explicit and visible in the constructor.
This clarity is one of its main advantages.


Common mistakes with dependency injection

Injecting concrete implementations everywhere
Dependency injection is most powerful when classes depend on abstractions.
If every service depends directly on concrete classes, you still have tight coupling, even if the container constructs them.
For example:

This is better than creating new PrismaClient() inside the service, but the service is still coupled to Prisma.
A repository abstraction can help:

Now the service depends on a capability, not a specific library.

Overusing dependency injection

Not every function needs to be a class with injected dependencies.
Simple utilities, pure functions, and small helpers may not benefit from dependency injection.
For example:

This function has no external dependencies.
Wrapping it in a class and injecting it through a container adds unnecessary complexity.
Use dependency injection where it solves a real problem: managing dependencies, improving testability, and reducing coupling.
Creating too many tiny services
Dependency injection can encourage splitting behavior into many small services.
This can be useful, but it can also create fragmentation.
If understanding one workflow requires jumping through twenty classes, the design may be over-engineered.
The goal is not maximum fragmentation.
The goal is clear responsibilities and manageable boundaries.
Ignoring the composition root
In a NestJS application, the framework handles much of the composition.
But in plain TypeScript or other environments, you need a place where the application is assembled.
This is often called the composition root.

It is where you:

  • Create concrete implementations.
  • Configure providers.
  • Wire dependencies together.
  • Start the application.

Keeping this logic centralized makes the system easier to understand and easier to configure for different environments.


Dependency injection and interfaces

Dependency injection works especially well with interfaces or abstract classes.
An interface defines a contract:

Multiple implementations can satisfy the contract:

The business logic depends on the interface:

This makes it easy to:

  • Switch providers.
  • Test with a fake implementation.
  • Intr oduce a new provider without modifying the checkout logic.

The class does not know how the payment is implemented.
It only knows what it can ask the payment gateway to do.


Dependency injection in a multi-tenant SaaS

In a multi-tenant SaaS platform, different tenants may require different configurations.
For example:

Different payment providers.
Different email providers.
Different storage backends.
Different feature flags.

Dependency injection can help you compose tenant-specific implementations.
A tenant context can determine which implementation to use:
The rest of the application depends on PaymentGateway, not on the specific provider.
This keeps tenant-specific logic contained and makes the core application more stable.


Dependency injection and testing strategies

Dependency injection enables several testing strategies.
Unit testing with fakes
You can provide simple in-memory or hardcoded implementations:
The service can be tested without a real database.
Unit testing with mocks
Mocking libraries can create objects that record how they were called:
This verifies that the service interacts correctly with its dependencies.
Integration testing with real dependencies
For integration tests, you can use real or containerized dependencies:

  • A test database.
  • A test message broker.
  • A test email service or stub.

Dependency injection makes it easier to swap between test and production configurations.


Dependency injection and clean architecture
Clean architecture and hexagonal architecture emphasize separating:

  • Domain logic.
  • Application logic.
  • Infrastructure.
  • Interfaces.

Dependency injection supports this separation by allowing inner layers to depend on abstractions while outer layers provide concrete implementations.
For example:

The application layer does not import infrastructure details.
It only defines the capabilities it needs.
This makes the system easier to evolve and easier to test.


When dependency injection may be overkill
Dependency injection is not required for every piece of code.
It is most valuable when:

  • A class has external dependencies.
  • You need to test the class in isolation.
  • Implementations may change.
  • Configuration differs between environments.
  • You want to reduce coupling.

It may be unnecessary when:

  • The code is a simple script.
  • The function has no external dependencies.
  • The behaviour is trivial and stable.
  • The overhead outweighs the benefit.

Use dependency injection intentionally, not as a default ritual.


A simple mental model

You can think of dependency injection as answering three questions:
What does this class need?
Declared in the constructor.
Who provides it?
The container, factory, or composition root.
How is it built?
The concrete implementation, configuration, or environment.
The class only answers the first question.
The other questions are handled externally.
This separation is what makes the code more flexible and easier to test.


Final thoughts

Dependency injection is not only a framework feature.
It is a design choice about who controls object creation and how dependencies are managed.
When your classes know how to build everything they need, they become tightly coupled to those implementations.
When your classes declare what they need and receive it from the outside, they become easier to test, easier to configure, and easier to change.
This does not mean every function must be a class with injected dependencies.
It means that when a component has external dependencies, those dependencies should be explicit, replaceable, and managed outside the component.
Because good software is not only about making each class work.
It is about designing a system where change does not require rewriting everything that depends on it.
And dependency injection is one of the most practical tools for achieving that.

Top comments (0)