In the previous article, we explored the Dependency Inversion Principle (DIP).
We saw how high-level business logic becomes tightly coupled when it directly creates and depends on implementation details.
We also briefly introduced another concept:
Dependency Injection (DI).
You may have heard this term many times.
You may have even used Dependency Injection without realizing it.
But instead of starting with its definition, let's start with a problem.
The Problem
Imagine we're building an order processing system.
Our OrderService needs to send an email when an order is created.
We might write:
class EmailService {
send(to: string, message: string) {
console.log(`Sending email to ${to}`);
}
}
class OrderService {
private emailService = new EmailService();
createOrder() {
// Create order...
this.emailService.send(
"customer@example.com",
"Your order has been placed."
);
}
}
Looks simple.
OrderService needs an EmailService, so it creates one.
What's wrong with that?
At first, nothing.
But let's see what happens as the application grows.
The First Problem: Changing the Dependency
Suppose the application initially uses one email provider.
Later, the business decides to switch to another provider.
Our OrderService is now directly connected to the original implementation.
We have to change it.
class OrderService {
private emailService = new NewEmailService();
}
The order logic shouldn't have to know which email service the company uses.
But because it creates the dependency itself, it has no choice.
The Bigger Problem: Testing
Now imagine we want to test OrderService.
We only want to test:
Does creating an order trigger an email?
But our test has a problem.
OrderService automatically creates a real EmailService.
So our test may end up:
- Connecting to an external service.
- Making unnecessary network requests.
- Depending on external configuration.
- Becoming slower.
- Becoming harder to make deterministic.
All because OrderService decided to create its own dependency.
What If We Could Provide the Dependency?
Instead of this:
class OrderService {
private emailService = new EmailService();
}
What if we did this?
class OrderService {
constructor(private emailService: EmailService) {}
createOrder() {
// Create order...
this.emailService.send(
"customer@example.com",
"Your order has been placed."
);
}
}
And outside the class:
const emailService = new EmailService();
const orderService = new OrderService(emailService);
Something important just happened.
OrderService no longer creates its dependency.
Someone else provides it.
That's Dependency Injection.
The Simplest Definition
Dependency Injection simply means:
A class receives the dependencies it needs from the outside instead of creating them itself.
That's it.
The dependency is injected into the object.
Why Is This Better?
Now OrderService doesn't care where the email service came from.
We can provide the real implementation:
const emailService = new EmailService();
const orderService = new OrderService(emailService);
Or a fake implementation during testing:
class FakeEmailService {
send(to: string, message: string) {
console.log("Fake email sent");
}
}
Then:
const fakeEmailService = new FakeEmailService();
const orderService = new OrderService(fakeEmailService);
The OrderService doesn't need to change.
It simply receives something that provides the behavior it needs.
But There Is Still a Problem
Our previous example depends directly on EmailService.
That's better than creating it internally, but we can take it one step further.
What if we define what OrderService actually needs?
It doesn't necessarily need an EmailService.
It needs something capable of sending emails.
So we define an abstraction.
interface EmailSender {
send(to: string, message: string): void;
}
Our actual service implements it:
class EmailService implements EmailSender {
send(to: string, message: string) {
console.log(`Sending email to ${to}`);
}
}
Now OrderService depends on the abstraction.
class OrderService {
constructor(private emailSender: EmailSender) {}
createOrder() {
// Create order...
this.emailSender.send(
"customer@example.com",
"Your order has been placed."
);
}
}
This is where Dependency Injection and Dependency Inversion work nicely together.
DI and DIP Are Not the Same Thing
These terms are often used interchangeably, but they mean different things.
Dependency Inversion Principle
DIP is a design principle.
It tells us that high-level modules shouldn't be tightly coupled to low-level implementation details.
Dependency Injection
DI is a technique.
It provides a practical way to supply dependencies from outside.
You can use Dependency Injection without perfectly following DIP.
And DIP can be achieved through different techniques.
But in practice, they are often used together.
Dependency Injection in Real Applications
You've probably already used Dependency Injection.
Consider a React component.
function UserProfile({ userService }: Props) {
// ...
}
The component doesn't create userService.
It receives it.
The same idea appears in:
- Backend services
- Database repositories
- API clients
- Logging systems
- Authentication providers
- Payment processors
- File storage
- Notification services
The pattern is always similar:
Instead of:
Class → creates dependency
We have:
Class ← receives dependency
**Does DI Always Require a Framework?
**
No.
This is another common misconception.
Dependency Injection doesn't require:
- NestJS
- Angular
- Spring
- .NET
- A DI container
This is Dependency Injection:
class OrderService {
constructor(private paymentService: PaymentService) {}
}
And:
const paymentService = new PaymentService();
const orderService = new OrderService(paymentService);
No framework.
No library.
Just JavaScript/TypeScript.
A DI framework simply automates the process of creating and connecting these dependencies.
The Composition Root
At some point, someone still has to create the objects.
We haven't eliminated object creation.
We've moved it to a better place.
For example:
const paymentService = new StripePaymentService();
const orderService = new OrderService(
paymentService
);
This part of the application is sometimes called the composition root.
It's where we decide:
Which implementation should be used?
The business logic doesn't need to know.
This creates a useful separation:
Application Setup
│
├── StripePaymentService
└── OrderService
│
↓
PaymentProcessor
The configuration decides the implementation.
The business logic simply uses the abstraction.
The Benefits of Dependency Injection
Easier Testing
Dependencies can easily be replaced with mocks, fakes, or stubs.
Easier Maintenance
Classes don't need to know how their dependencies are created.
Lower Coupling
Business logic becomes less dependent on concrete implementations.
Greater Flexibility
Different implementations can be provided without changing the consuming class.
But Is DI Always Better?
Not necessarily.
Dependency Injection also introduces some complexity.
Compare:
const orderService = new OrderService();
with:
const orderService = new OrderService(
paymentService,
emailService,
inventoryService,
logger,
analyticsService
);
The second approach can become difficult to understand if taken too far.
You can also end up with a codebase where every class has an interface, every dependency is injected, and simple object creation becomes unnecessarily complicated.
Again, the goal isn't to apply a principle everywhere.
The goal is to solve a real problem.
Concluding This Series — For Now 🙂
With this article, we've reached a natural stopping point for this part of the series.
We started with the fundamentals of OOP, explored its core concepts, questioned where Factory Functions fit in, and then moved into SOLID Principles and Dependency Injection.
But software design is a much bigger topic.
There are still many areas worth exploring—from Design Patterns and Inversion of Control to architecture, coupling, cohesion, and many of the practical decisions we face while building real-world software.
We'll continue exploring these topics in future articles, always following the same approach:
Don't just learn what a concept is. Understand why it exists, what problem it solves, and when it actually makes sense to use it.
For now, this is where we'll conclude this part of the journey.
See you in the next one. 🚀

Top comments (0)