Dependency Injection in Its Simplest Form
Dependency Injection has a reputation for being a difficult, academic topic. But at its core, it is a remarkably simple concept: instead of an object creating the things it needs, those things are handed to it from the outside.
Consider a Car class that creates its own engine:
import { Engine } from './Engine';
class Car {
private engine: Engine;
public constructor () {
this.engine = new Engine();
}
public startEngine(): void {
this.engine.fireCylinders();
}
}
Now consider a Car that receives an engine through its constructor:
import { Engine } from './Engine';
class Car {
private engine: Engine;
public constructor (engine: Engine) {
this.engine = engine;
}
public startEngine(): void {
this.engine.fireCylinders();
}
}
That difference — passing a dependency in rather than letting the dependent object create it — is the entire idea. When class Car uses the functionality of class Engine, then Engine is a dependency of Car. Injection simply means the dependency is supplied from a higher level of control.
Why Inject at All
The most immediate benefit of this pattern is testability. In the first example, the Car class has no way to use anything other than a real Engine instance. You cannot mock or substitute it, because the class instantiates the engine itself. Every test runs against the real implementation.
With injection, the caller controls exactly which Engine is used. To test how Car.startEngine() behaves when engine.fireCylinders() throws, you could create a FakeEngine that extends Engine and overrides fireCylinders to throw an error. Because FakeEngine is an Engine by inheritance, the TypeScript type system remains satisfied, and the fake can be injected into the Car constructor. Overriding methods via inheritance is not necessarily the ideal testing strategy — that point will come up later — but it works.
There is also a design argument. A Car should not have the responsibility of deciding which engine or wheels it uses. Those decisions depend on requirements known only at construction time: whether the engine can safely spin wheels at a given RPM, for example. The engineers who build the car understand those constraints; the car itself does not. By keeping dependency selection at a higher level of control, the class remains focused on its own behavior rather than on the configuration of its parts.
This makes even more sense if you imagine Engine as an abstract class. The Car knows it needs an engine and knows what basic functionality that engine must provide, but it does not need to know anything about the specific implementation. That detail belongs to the code that constructs the car.
Building a Coupled Example First
To see the practical payoff of dependency injection, it helps to start with code that has no DI at all. Consider an application with two collaborators: a data access layer and an email provider, both consumed by a UserService. The first version of the data access layer hardcodes its database driver.
// UserRepository.ts
import { dbDriver } from 'pg-driver';
export class UserRepository {
public async addUser(user: User): Promise<void> {
// ... dbDriver.save(...)
}
public async findUserById(id: string): Promise<User> {
// ... dbDriver.query(...)
}
public async existsByEmail(email: string): Promise<boolean> {
// ... dbDriver.save(...)
}
}
When used inside UserService, the repository is instantiated directly and used immediately.
import { UserRepository } from './UserRepository.ts';
class UserService {
private readonly userRepository: UserRepository;
public constructor () {
// Not dependency injection.
this.userRepository = new UserRepository();
}
public async registerUser(dto: IRegisterUserDto): Promise<void> {
// User object & validation
const user = User.fromDto(dto);
if (await this.userRepository.existsByEmail(dto.email))
return Promise.reject(new DuplicateEmailError());
// Database persistence
await this.userRepository.addUser(user);
// Send a welcome email
// ...
}
public async findUserById(id: string): Promise<User> {
// No need for await here, the promise will be unwrapped by the caller.
return this.userRepository.findUserById(id);
}
}
Note: A DTO, or Data Transfer Object, is a property bag that standardizes the shape of data between layers or systems. For brevity, the DTO in this example only carries
idandfindUserByIdmethod omits mapping to a response DTO. A production domain model would also not hold afromDtomethod; it's simplified here.
The email provider follows the same pattern: create the class, import it, use it.
// SendGridEmailProvider.ts
import { sendMail } from 'sendgrid';
export class SendGridEmailProvider {
public async sendWelcomeEmail(to: string): Promise<void> {
// ... await sendMail(...);
}
}
import { UserRepository } from './UserRepository.ts';
import { SendGridEmailProvider } from './SendGridEmailProvider.ts';
class UserService {
private readonly userRepository: UserRepository;
private readonly sendGridEmailProvider: SendGridEmailProvider;
public constructor () {
// Still not doing dependency injection.
this.userRepository = new UserRepository();
this.sendGridEmailProvider = new SendGridEmailProvider();
}
public async registerUser(dto: IRegisterUserDto): Promise<void> {
// User object & validation
const user = User.fromDto(dto);
if (await this.userRepository.existsByEmail(dto.email))
return Promise.reject(new DuplicateEmailError());
// Database persistence
await this.userRepository.addUser(user);
// Send welcome email
await this.sendGridEmailProvider.sendWelcomeEmail(user.email);
}
public async findUserById(id: string): Promise<User> {
return this.userRepository.findUserById(id);
}
}
The Problems With Hard-Coded Dependencies
This coupled setup works until change arrives. Swapping SendGrid for Mailchimp requires editing UserService. Unit testing means either touching a real database or sending real emails — both undesirable. The usual JavaScript answer is heavy mocking libraries, which add indirection and, worse, couple the test to the implementation of the system under test. Tests should behave like black boxes: they verify behavior without knowing how the system works internally.
The root issue is responsibility. UserService exists to execute user-related use cases: registration, reading, updating. Controlling the lifetimes of UserRepository and SendGridEmailProvider is not part of that job. Dependencies can be singletons, transient, or scoped to an HTTP request; deciding that is the caller's problem, not the service's.
Injecting Dependencies Through the Constructor
Passing pre-instantiated objects into UserService moves control up to the calling code.
import { UserRepository } from './UserRepository.ts';
import { SendGridEmailProvider } from './SendGridEmailProvider.ts';
class UserService {
private readonly userRepository: UserRepository;
private readonly sendGridEmailProvider: SendGridEmailProvider;
public constructor (
userRepository: UserRepository,
sendGridEmailProvider: SendGridEmailProvider
) {
// Yay! Dependencies are injected.
this.userRepository = userRepository;
this.sendGridEmailProvider = sendGridEmailProvider;
}
public async registerUser(dto: IRegisterUserDto): Promise<void> {
// User object & validation
const user = User.fromDto(dto);
if (await this.userRepository.existsByEmail(dto.email))
return Promise.reject(new DuplicateEmailError());
// Database persistence
await this.userRepository.addUser(user);
// Send welcome email
await this.sendGridEmailProvider.sendWelcomeEmail(user.email);
}
public async findUserById(id: string): Promise<User> {
return this.userRepository.findUserById(id);
}
}
Now whoever constructs UserService manages the dependency lifetimes. But two design problems remain. First, UserService still knows the email provider is SendGrid. Second, both dependencies are concrete classes, locking UserService into specific implementations. To swap providers or fake them for tests, the service needs to depend on abstractions, not concrete classes.
Depend on Interfaces, Not Implementations
The fix is to define interfaces for both dependencies and require that incoming objects honor them. The repository interface comes first.
// UserRepository.ts
import { dbDriver } from 'pg-driver';
export interface IUserRepository {
addUser(user: User): Promise<void>;
findUserById(id: string): Promise<User>;
existsByEmail(email: string): Promise<boolean>;
}
export class UserRepository implements IUserRepository {
public async addUser(user: User): Promise<void> {
// ... dbDriver.save(...)
}
public async findUserById(id: string): Promise<User> {
// ... dbDriver.query(...)
}
public async existsByEmail(email: string): Promise<boolean> {
// ... dbDriver.save(...)
}
}
Next, the email provider interface.
// IEmailProvider.ts
export interface IEmailProvider {
sendWelcomeEmail(to: string): Promise<void>;
}
// SendGridEmailProvider.ts
import { sendMail } from 'sendgrid';
import { IEmailProvider } from './IEmailProvider';
export class SendGridEmailProvider implements IEmailProvider {
public async sendWelcomeEmail(to: string): Promise<void> {
// ... await sendMail(...);
}
}
Note: Applying an interface to an existing third-party class follows the Adapter Pattern from the Gang of Four.
With those interfaces in place, UserService can be rewritten to accept them.
import { IUserRepository } from './UserRepository.ts';
import { IEmailProvider } from './SendGridEmailProvider.ts';
class UserService {
private readonly userRepository: IUserRepository;
private readonly emailProvider: IEmailProvider;
public constructor (
userRepository: IUserRepository,
emailProvider: IEmailProvider
) {
// Double yay! Injecting dependencies and coding against interfaces.
this.userRepository = userRepository;
this.emailProvider = emailProvider;
}
public async registerUser(dto: IRegisterUserDto): Promise<void> {
// User object & validation
const user = User.fromDto(dto);
if (await this.userRepository.existsByEmail(dto.email))
return Promise.reject(new DuplicateEmailError());
// Database persistence
await this.userRepository.addUser(user);
// Send welcome email
await this.emailProvider.sendWelcomeEmail(user.email);
}
public async findUserById(id: string): Promise<User> {
return this.userRepository.findUserById(id);
}
}
The wall outlet analogy makes this clear. A toaster plugs into a standardized outlet, not directly into the wiring. The outlet is the abstraction: it hides the voltage, current, and frequency, and it lets you swap the source of electricity without touching the toaster. Interfaces play the same role for software components. UserService only needs to know what functionality its dependencies offer, not how they deliver it.
Changing email providers now means writing a new Mailchimp-backed class that implements IEmailProvider, then injecting it. The UserService class stays untouched. This is the practical benefit of coding against abstractions: the interface is the architectural boundary, and both sides can evolve independently as long as the contract holds.
Testing With Hand-Written Fakes
The same flexibility enables straightforward tests. Instead of mocking libraries like Jest's built-in tooling or monkey-patching, you can write simple fakes that implement the same interfaces.
// Both fakes:
class FakeUserRepository implements IUserRepository {
private readonly users: User[] = [];
public async addUser(user: User): Promise<void> {
this.users.push(user);
}
public async findUserById(id: string): Promise<User> {
const userOrNone = this.users.find(u => u.id === id);
return userOrNone
? Promise.resolve(userOrNone)
: Promise.reject(new NotFoundError());
}
public async existsByEmail(email: string): Promise<boolean> {
return Boolean(this.users.find(u => u.email === email));
}
public getPersistedUserCount = () => this.users.length;
}
class FakeEmailProvider implements IEmailProvider {
private readonly emailRecipients: string[] = [];
public async sendWelcomeEmail(to: string): Promise<void> {
this.emailRecipients.push(to);
}
public wasEmailSentToRecipient = (recipient: string) =>
Boolean(this.emailRecipients.find(r => r === recipient));
}
Both fakes honor the exact interfaces UserService expects. Passing them in works because the service only relies on the methods and properties declared in those interfaces — it never touches the concrete implementation details. The tests then become direct and readable.
// Fakes
let fakeUserRepository: FakeUserRepository;
let fakeEmailProvider: FakeEmailProvider;
// SUT
let userService: UserService;
// We want to clean out the internal arrays of both fakes
// before each test.
beforeEach(() => {
fakeUserRepository = new FakeUserRepository();
fakeEmailProvider = new FakeEmailProvider();
userService = new UserService(fakeUserRepository, fakeEmailProvider);
});
// A factory to easily create DTOs.
// Here, we have the optional choice of overriding the defaults
// thanks to the built in `Partial` utility type of TypeScript.
function createSeedRegisterUserDto(opts?: Partial<IRegisterUserDto>): IRegisterUserDto {
return {
id: 'someId',
email: '[email protected]',
...opts
};
}
test('should correctly persist a user and send an email', async () => {
// Arrange
const dto = createSeedRegisterUserDto();
// Act
await userService.registerUser(dto);
// Assert
const expectedUser = User.fromDto(dto);
const persistedUser = await fakeUserRepository.findUserById(dto.id);
const wasEmailSent = fakeEmailProvider.wasEmailSentToRecipient(dto.email);
expect(persistedUser).toEqual(expectedUser);
expect(wasEmailSent).toBe(true);
});
test('should reject with a DuplicateEmailError if an email already exists', async () => {
// Arrange
const existingEmail = '[email protected]';
const dto = createSeedRegisterUserDto({ email: existingEmail });
const existingUser = User.fromDto(dto);
await fakeUserRepository.addUser(existingUser);
// Act, Assert
await expect(userService.registerUser(dto))
.rejects.toBeInstanceOf(DuplicateEmailError);
expect(fakeUserRepository.getPersistedUserCount()).toBe(1);
});
test('should correctly return a user', async () => {
// Arrange
const user = User.fromDto(createSeedRegisterUserDto());
await fakeUserRepository.addUser(user);
// Act
const receivedUser = await userService.findUserById(user.id);
// Assert
expect(receivedUser).toEqual(user);
});
The fakes themselves are remarkably simple, deliberately avoiding framework complexity and magic. The tests use async/await even though the fakes are synchronous, which keeps them runnable against real implementations as well. In the first test, the arrange step builds a DTO; the act step calls the service; and the assertion asks the fake repository for the persisted user, then checks the email record. The second test pre-seeds a user, expects a duplicate registration to fail, and verifies no extra data landed in the repository. The third persists a user up front and checks that reads return the saved entity.
In practice, mocking every dependency is usually a mistake. The "unit" in unit testing means a unit of behavior, which can span multiple classes. Fakes are only needed when dependencies cross the module boundary — a database wire or an email network call. A local database in Docker is often a fine first choice for a real repository; fake it only if tests get too slow. Email providers are a different story: sending real welcome messages is unnecessary and potentially costly. No matter the mock strategy, avoid asserting on call counts or exact parameters except in rare cases, since that leaks implementation details into tests that should stay agnostic to them.
Functional Dependency Injection
The constructor injection examples so far rely on classes. But DI is not class-specific — a functional style can reap the same benefits by passing dependencies as function arguments. The UserService can become a factory that accepts its dependencies and returns a service object:
function makeUserService(
userRepository: IUserRepository,
emailProvider: IEmailProvider
): IUserService {
return {
registerUser: async dto => {
// ...
},
findUserById: id => userRepository.findUserById(id)
}
}
The same pattern works for Higher Order Functions. For instance, an Express middleware can be created by a factory that receives a UserRepository and an ILogger and returns the actual middleware:
function authProvider(userRepository: IUserRepository, logger: ILogger) {
return async (req: Request, res: Response, next: NextFunction) => {
// ...
// Has access to userRepository, logger, req, res, and next.
}
}
In the factory example above, the parameters dto and id do not need explicit type annotations. If an interface like IUserService describes the method signatures, TypeScript infers them. Similarly, if the return type of the authProvider factory is the declared Express middleware signature, argument types are also inferred automatically.
When the email provider and repository are themselves built functionally, with their own dependencies injected instead of hardcoded, the application root composes everything from the top down:
import { sendMail } from 'sendgrid';
async function main() {
const app = express();
const dbConnection = await connectToDatabase();
// Change emailProvider to `makeMailChimpEmailProvider` whenever we want
// with no changes made to dependent code.
const userRepository = makeUserRepository(dbConnection);
const emailProvider = makeSendGridEmailProvider(sendMail);
const userService = makeUserService(userRepository, emailProvider);
// Put this into another file. It’s a controller action.
app.post('/login', (req, res) => {
await userService.registerUser(req.body as IRegisterUserDto);
return res.send();
});
// Put this into another file. It’s a controller action.
app.delete(
'/me',
authProvider(userRepository, emailProvider),
(req, res) => { ... }
);
}
At the root, you fetch concrete third-party dependencies — a database connection or library functions — then use factories to build your first-party dependencies on top of them, and finally pass those into the code that needs them. Because every collaborator depends on an abstraction rather than a concrete implementation, swapping userRepository for any other function or class that honors the same interface requires zero changes inside UserService; it only observes the public surface of its dependencies.
One caveat about this demonstration: the code was optimized to highlight DI mechanics, not enterprise design patterns. In production, you would manage transactions across repositories and map DTOs in the controller layer rather than passing them into service methods. The userSerivce.findById method also skips mapping the domain User to a DTO. None of that affects the DI implementation — it is excluded deliberately so the focus stays on dependency injection itself. Also, although the style might evoke NestJS, it is not NestJS, and NestJS is intentionally avoided here for reasons outside the scope of this piece.
The Theory in Brief
Every application is a set of collaborating components. How those collaborators are wired and managed determines how readily the application resists refactoring, change, and testing. Dependency injection combined with coding against interfaces is a primary tool for lowering coupling between collaborators, which makes them easy to swap. That is the signature of high cohesion and loose coupling.
Non-trivial systems need their components decoupled to stay maintainable. The mechanism, as shown throughout, is depending upon abstractions — interfaces — rather than concrete implementations, and letting DI deliver those dependencies. This yields loose coupling, lets you replace implementations without touching the dependent side, and spares the dependent code from managing the lifetimes of its dependencies. This does not mean every single object must be injected. Utilities, mappers, models, and similar collaborators cause no trouble when coupled directly.
The simple picture above hides real complexity in DI. Injection comes in several forms beyond the Constructor Injection used here. With Setter Injection, a dependency exposes a setter such as setUserRepository(userRepository: UserRepository). Interface Injection defines a dedicated interface through which injection happens; that variant is left aside here because it gets full treatment later in this series.
Manual wiring becomes cumbersome at scale, which is why Inversion of Control (IoC) frameworks and containers exist. They store dependencies and resolve them at runtime, often using Reflection in C# or Java, and expose configuration for dependency lifetimes. There are solid arguments against containers too; Greg Young's 8 Lines of Code talk makes the case for manual resolution.
Containers can also offer too much, and many rely on decorators or attributes for setter or field injection. This approach contradicts DI's purpose: you decouple from one dependency only to couple your business logic to the container itself. Containers such as Awilix by Jeff Hansen avoid this by remaining fully uninvolved with your application code.
Closing Thoughts
This article deliberately presented a purely practical DI example, keeping theory mostly out of the way, so the core idea is visible without the usual surrounding baggage. A follow-up article goes much deeper, covering:
- The difference between Dependency Injection, Dependency Inversion, and Inversion of Control;
- DI anti-patterns;
- IoC Container anti-patterns;
- The role of IoC Containers;
- Dependency lifetimes;
- How IoC Containers are designed internally;
- DI with React;
- Advanced testing scenarios;
- more.



