Dependency Injection

Milan Herda

October, 2025

We will be talking about:

  • DI
  • IoC
  • DIP
  • Service Container
  • DIC
  • Dependency Injection
  • Inversion of Control
  • Dependency Inversion Principle
  • Service Container
  • Dependency Injection Container (DI Container)

What are dependencies

Chapter 1:

// src/services/userRepository.ts

export function getCurrentUser(): User {
    // ...	
}

What does this function need to work properly?

// src/services/userRepository.ts

export function getCurrentUser(): User {
    // ...	
}

What does this function need to work properly?

We do not know.

It is not written in its documentation, nor declaration

// src/services/userRepository.ts

export function getCurrentUser(): User {
	const db = createDbConnection("mysql://user:password@localhost:3306/mydb");

	const rows = db
		.select("*")
		.from("users")
		.where("id = :id", { id: session.userId })
		.execute<UserData>();

	if (rows.length === 0) {
		throw new Error("User not found");
	}

	const userData = rows[0];

	return {
		id: userData.id,
		name: userData.name,
		surname: userData.surname,
		email: userData.email,
		getFullName() {
			return `${this.name} ${this.surname}`;
		},
	};
}

What does this function need to work properly?

What does this function need to work properly?

  • proper refactoring because it does too many things
    • creates a new database connection
    • queries the database
    • transforms raw table data to an entity object

But besides of that...

// src/services/userRepository.ts

export function getCurrentUser(): User {
	const db = createDbConnection("mysql://user:password@localhost:3306/mydb");

	const rows = db
		.select("*")
		.from("users")
		.where("id = :id", { id: session.userId })
		.execute<UserData>();

	if (rows.length === 0) {
		throw new Error("User not found");
	}

	const userData = rows[0];

	return {
		id: userData.id,
		name: userData.name,
		surname: userData.surname,
		email: userData.email,
		getFullName() {
			return `${this.name} ${this.surname}`;
		},
	};
}

DB Query object

Session object

User entity creation logic

DSN string

provided by foreign function

Our function needs those objects and functionality.

It requires them in order to work properly.

It depends on them.

They are its dependencies.

// src/services/userRepository.ts

export function getCurrentUser(): User {
	const db = createDbConnection("mysql://user:password@localhost:3306/mydb");

	const rows = db
		.select("*")
		.from("users")
		.where("id = :id", { id: session.userId })
		.execute<UserData>();

	if (rows.length === 0) {
		throw new Error("User not found");
	}

	const userData = rows[0];

	return {
		id: userData.id,
		name: userData.name,
		surname: userData.surname,
		email: userData.email,
		getFullName() {
			return `${this.name} ${this.surname}`;
		},
	};
}

Problems?

// src/services/userRepository.ts

export function getCurrentUser(): User {
    // ...
}

Problems?

  • dependencies are not visible
    • we do not know what is needed and what must be set up before calling this function
  • reusing is problematic
    • hard to guess, what is needed for it to work
  • similar code can be duplicated in multiple functions
  • even a change not related to the primary responsibility must be done inside the function

Why do programmers tend to write code in this way?

  • not enough experience, or out of habit
  • real benefits:
    • collocation (related stuff is close together)
    • all the needed code is "encapsulated" inside
      • no other constraints or demands on clients (callers of our function)

This one is a lie we tell ourselves

Control

Chapter 2:

// src/services/userRepository.ts

export function getCurrentUser(): User {
	const db = createDbConnection("mysql://user:password@localhost:3306/mydb");

	const rows = db
		.select("*")
		.from("users")
		.where("id = :id", { id: session.userId })
		.execute<UserData>();

	if (rows.length === 0) {
		throw new Error("User not found");
	}

	const userData = rows[0];

	return {
		id: userData.id,
		name: userData.name,
		surname: userData.surname,
		email: userData.email,
		getFullName() {
			return `${this.name} ${this.surname}`;
		},
	};
}

What is in control over dependencies?

This function is in control

The function is in control

  • over all its dependencies
  • and every single detail how they are used and what they need

But we ultimately create applications (or libraries), not single functions

If functions are in control, ...

...does it mean...

...that my application is...

Not In Control?

We should invert this control

Inversion of Control

  • we invert the flow of control from smaller units to the bigger ones

Inversion of Control

  • we will not manage our dependencies ourselves
  • we just ask for them
  • the function simply does not care where is the origin of dependencies
    • it just tell all the callers that it needs them

We ask clients (callers) to inject those dependencies to our function

// src/services/userRepository.ts

export function getCurrentUser(db: DbQuery, session: Session): User {
	const rows = db
		.select("*")
		.from("users")
		.where("id = :id", { id: session.userId })
		.execute<UserData>();

	if (rows.length === 0) {
		throw new Error("User not found");
	}

	const userData = rows[0];

	return {
		id: userData.id,
		name: userData.name,
		surname: userData.surname,
		email: userData.email,
		getFullName() {
			return `${this.name} ${this.surname}`;
		},
	};
}

Inverting dependencies

// src/somewhere/else.ts

import session from './services/session';
import { getCurrentUser } from './services/userRepository';

const db = createDbConnection("mysql://user:password@localhost:3306/mydb");

const currentUser = getCurrentUser(db, session)

Injecting dependencies

// src/services/userRepository.ts

export function getCurrentUser(db: DbQuery, session: Session): User {
	const rows = db
		.select("*")
		.from("users")
		.where("id = :id", { id: session.userId })
		.execute<UserData>();

	if (rows.length === 0) {
		throw new Error("User not found");
	}

	const userData = rows[0];

	return {
		id: userData.id,
		name: userData.name,
		surname: userData.surname,
		email: userData.email,
		getFullName() {
			return `${this.name} ${this.surname}`;
		},
	};
}

Inverting dependencies

Do we need session, or just userId?

// src/services/userRepository.ts

export function getCurrentUser(db: DbQuery, userId: number): User {
	const rows = db
		.select("*")
		.from("users")
		.where("id = :id", { id: userId })
		.execute<UserData>();

	if (rows.length === 0) {
		throw new Error("User not found");
	}

	const userData = rows[0];

	return {
		id: userData.id,
		name: userData.name,
		surname: userData.surname,
		email: userData.email,
		getFullName() {
			return `${this.name} ${this.surname}`;
		},
	};
}

Inverting dependencies

What to do with this "algorithm" dependency?

// src/services/userRepository.ts

export function getCurrentUser(db: DbQuery, userId: number): User {
	const rows = db
		.select("*")
		.from("users")
		.where("id = :id", { id: userId })
		.execute<UserData>();

	if (rows.length === 0) {
		throw new Error("User not found");
	}

	return createUser(rows[0]);
}

function createUser(data: UserData) {
	return {
		id: userData.id,
		name: userData.name,
		surname: userData.surname,
		email: userData.email,
		getFullName() {
			return `${this.name} ${this.surname}`;
		},
	};
}

Inverting dependencies

We can apply

Single Responsibility Principle*

*extraction to its own function

We start with basic techniques

And ask SOLID principles for help

// src/services/userRepository.ts

export function getCurrentUser(
	db: DbQuery,
	userId: number,
	createUser: UserFactoryFunc
): User {
	const rows = db
		.select("*")
		.from("users")
		.where("id = :id", { id: userId })
		.execute<UserData>();

	if (rows.length === 0) {
		throw new Error("User not found");
	}

	return createUser(rows[0]);
}

Inverting dependencies

If it makes sense, the extracted function can be moved to its own module (file).

 

When this happens, we know that such function was actually a hidden dependency right from the start

// src/somewhere/else.ts

import session from './services/session';
import createUser from './services/userFactory';
import { getCurrentUser } from './services/userRepository';

const db = createDbConnection("mysql://user:password@localhost:3306/mydb");

const currentUser = getCurrentUser(db, session.userId, createUser);

Injecting dependencies

Is it okay to have so many parameters in my function?

Probably not*

*Unless it is a factory function, or constructor method

The real code is usually more complex and allow us to refactor it

If not, we can still make use of closures

and/or currying

// src/somewhere/else.ts

import session from './services/session';
import createUser from './services/userFactory';
import { getCurrentUser } from './services/userRepository';

const db = createDbConnection("mysql://user:password@localhost:3306/mydb");

const currentUser = getCurrentUser(db, session.userId, createUser);

Injecting dependencies

// src/somewhere/else.ts

import session from './services/session';
import createUser from './services/userFactory';
import { getCurrentUser as getCurrentUserOriginal } from './services/userRepository';

const db = createDbConnection("mysql://user:password@localhost:3306/mydb");

function getCurrentUser(userId: number) {
    return getCurrentUserOriginal(db, userId, createUser);
}

const currentUser = getCurrentUser(session.userId);

Injecting dependencies

We can move this wrapper function to its own file and import on all places instead of the original

In our case, userRepository provides more methods

// src/services/userRepository.ts

export function getCurrentUser(
	db: DbQuery,
	userId: number,
	createUser: UserFactoryFunc
): User {
	const rows = db
		.select("*")
		.from("users")
		.where("id = :id", { id: userId })
		.execute<UserData>();

	if (rows.length === 0) {
		throw new Error("User not found");
	}

	return createUser(rows[0]);
}

export function getAllUsers(
	db: DbQuery,
	createUser: UserFactoryFunc
): User[] {
	const rows = db
		.select("*")
		.from("users")
		.execute<UserData>();

	return rows.map((row) => createUser(row)); 
}

In our case, userRepository provides more methods

// src/services/userRepository.ts

export function getUser(
	db: DbQuery,
	userId: number,
	createUser: UserFactoryFunc
): User {
	const rows = db
		.select("*")
		.from("users")
		.where("id = :id", { id: userId })
		.execute<UserData>();

	if (rows.length === 0) {
		throw new Error("User not found");
	}

	return createUser(rows[0]);
}

export function getAllUsers(
	db: DbQuery,
	createUser: UserFactoryFunc
): User[] {
	const rows = db
		.select("*")
		.from("users")
		.execute<UserData>();

	return rows.map((row) => createUser(row)); 
}

In our case, userRepository provides more methods

// src/services/userRepository.ts

export default function createUserRepository(
	db: DbQuery,
	createUser: UserFactoryFunc
) {
	return {
  		getUser(userId: number): User {
			const rows = db
				.select("*")
				.from("users")
				.where("id = :id", { id: userId })
				.execute<UserData>();

			if (rows.length === 0) {
				throw new Error("User not found");
			}

			return createUser(rows[0]);
		},
		getAllUsers(): User[] {
			const rows = db
				.select("*")
				.from("users")
				.execute<UserData>();

			return rows.map((row) => createUser(row)); 
		}
	}
}
// src/somewhere/else.ts

import session from './services/session';
import createUser from './services/userFactory';
import createUserRepository from './services/userRepository';

const db = createDbConnection("mysql://user:password@localhost:3306/mydb");

const userRepository = createUserRepository(db, createUser);

const currentUser = userRepository.getUser(session.userId);

Injecting dependencies

Inversion

Chapter 3:

// src/services/userRepository.ts

export default function createUserRepository(
	db: DbQuery,
	createUser: UserFactoryFunc
) {
	return {
  		getUser(userId: number): User {
			const rows = db
				.select("*")
				.from("users")
				.where("id = :id", { id: userId })
				.execute<UserData>();

			if (rows.length === 0) {
				throw new Error("User not found");
			}

			return createUser(rows[0]);
		},
		getAllUsers(): User[] {
			const rows = db
				.select("*")
				.from("users")
				.execute<UserData>();

			return rows.map((row) => createUser(row)); 
		}
	}
}

Some of you, may have written the repository not like this

But more like this

// src/services/userRepository.ts

import db from './services/dbConnection';
import createUser from './services/userFactory';

export default function createUserRepository() {
	return {
  		getUser(userId: number): User {
			const rows = db
				.select("*")
				.from("users")
				.where("id = :id", { id: userId })
				.execute<UserData>();

			if (rows.length === 0) {
				throw new Error("User not found");
			}

			return createUser(rows[0]);
		},
		getAllUsers(): User[] {
			const rows = db
				.select("*")
				.from("users")
				.execute<UserData>();

			return rows.map((row) => createUser(row)); 
		}
	}
}

While it works, there is one problem

The code now depends on concrete implementations for database query and user factory.

In case we want to replace them, we would have to modify the userRepository code

Dependency Inversion Principle

  • 5th of SOLID principles
  • teaches us to depend upon abstractions
  • in that way our code becomes more configurable
  • it is much easier to replace an abstraction than a concrete thing

Imagine you want to write unit tests for this code

// src/services/userRepository.ts

import db from './services/dbConnection';
import createUser from './services/userFactory';

export default function createUserRepository() {
	return {
  		getUser(userId: number): User {
			const rows = db
				.select("*")
				.from("users")
				.where("id = :id", { id: userId })
				.execute<UserData>();

			if (rows.length === 0) {
				throw new Error("User not found");
			}

			return createUser(rows[0]);
		},
		getAllUsers(): User[] {
			const rows = db
				.select("*")
				.from("users")
				.execute<UserData>();

			return rows.map((row) => createUser(row)); 
		}
	}
}

Impossible without mocking these

// src/services/userRepository.ts

export default function createUserRepository(
	db: DbQuery,
	createUser: UserFactoryFunc
) {
	return {
  		getUser(userId: number): User {
			const rows = db
				.select("*")
				.from("users")
				.where("id = :id", { id: userId })
				.execute<UserData>();

			if (rows.length === 0) {
				throw new Error("User not found");
			}

			return createUser(rows[0]);
		},
		getAllUsers(): User[] {
			const rows = db
				.select("*")
				.from("users")
				.execute<UserData>();

			return rows.map((row) => createUser(row)); 
		}
	}
}

Do we need module mocking in unit tests for this?

No

We can provide our own implementations.

We just have to fulfill the minimum contract

Unit tests are just an example of requirement to provide different implementation.

This requirement is already an integral part of all of our code.

If we stick to Dependency Inversion Principle and depend only on abstractions, then our code is not only easier to test, but also easier to modify

What have we learned so far

Inversion of Control is about taking control from small units and giving it to the bigger units (application, or framework in the end).

Dependency Injection is about providing dependencies to functions, objects or modules. It is just passing arguments to functions.

Dependency Inversion Principle tells us to depend on abstractions, not on concrete implementations.

When we meticulously apply IoC, DI and DIP, where does it end?

When all functions are asking for dependencies, something somewhere must be ultimately responsible for creation of all of them.

Containers

Chapter 4:

Next Time

Thank you!

Any questions?

Dependency Injection

By Milan Herda

Dependency Injection

  • 51