Dependency Injection Explained: From Concept to Production Code
Dependency Injection is the design pattern that separates what your code does from how it gets what it needs. Learn the core concept, all three injection types, and see real examples in Dart/Flutter, JavaScript, and Python, from manual DI to full framework implementations.

Every class needs things to do its job. A UserService needs a database connection. An OrderService needs a payment gateway. The question is not what your class needs — it is how it gets those things. That single decision shapes how testable, maintainable, and flexible your entire codebase becomes.
Dependency Injection (DI) is the answer to that question. Instead of a class reaching out and creating its own dependencies, those dependencies are handed to it from the outside. It sounds simple because it is — but the implications at scale are enormous.
The Core Problem: Tight Coupling
Before DI, most developers write code that looks like this — a class that creates everything it needs internally:
// Without DI — tightly coupled
class OrderService {
constructor() {
// OrderService CREATES its own dependency
this.database = new MySQLDatabase();
this.emailer = new SmtpEmailer();
this.logger = new FileLogger();
}
async placeOrder(userId, items) {
const user = await this.database.findUser(userId);
const order = await this.database.saveOrder(userId, items);
await this.emailer.send(user.email, "Order confirmed");
this.logger.log(`Order ${order.id} placed`);
return order;
}
}
This works. But it has three hidden problems. First, you cannot test OrderService without a real MySQL database, a real SMTP server, and a real log file. Second, if you want to switch from MySQL to PostgreSQL, you have to open OrderService and change it — a class that has nothing to do with databases. Third, every instance of OrderService creates its own database connection, which is wasteful and hard to manage.
The Fix: Inject the Dependencies
// With DI — loosely coupled
class OrderService {
constructor(database, emailer, logger) {
// OrderService RECEIVES its dependencies — it doesn't create them
this.database = database;
this.emailer = emailer;
this.logger = logger;
}
async placeOrder(userId, items) {
const user = await this.database.findUser(userId);
const order = await this.database.saveOrder(userId, items);
await this.emailer.send(user.email, "Order confirmed");
this.logger.log(`Order ${order.id} placed`);
return order;
}
}
// Wiring happens outside — one place, full control
const orderService = new OrderService(
new MySQLDatabase(),
new SmtpEmailer(),
new FileLogger()
);
OrderService no longer knows or cares what kind of database it uses. It just knows it will receive something that has a findUser and saveOrder method. Swap the database, the emailer, the logger — OrderService never changes.
Inversion of Control (IoC)
DI is a form of a broader principle called Inversion of Control. In traditional code, your class controls its own dependencies. With IoC, that control is inverted — an external container or caller decides what gets created and when. Your class just declares what it needs, and the framework or the calling code provides it. This is the philosophy behind DI frameworks in every language — Angular, Spring, GetIt, Provider, .NET's built-in DI container, and many more.
The Three Types of Dependency Injection
1. Constructor Injection
Dependencies are passed through the constructor. This is the most common and recommended approach — dependencies are required, visible, and immutable after construction.
// Constructor Injection — JavaScript
class UserService {
constructor(userRepository, cacheService) {
this.userRepository = userRepository;
this.cacheService = cacheService;
}
async getUser(id) {
const cached = await this.cacheService.get(id);
if (cached) return cached;
return await this.userRepository.findById(id);
}
}
2. Setter / Property Injection
Dependencies are provided after construction via setter methods or direct property assignment. Useful for optional dependencies.
// Setter Injection — JavaScript
class ReportService {
setLogger(logger) {
this.logger = logger;
}
generate(data) {
if (this.logger) {
this.logger.log("Generating report...");
}
// generate report...
}
}
const service = new ReportService();
service.setLogger(new ConsoleLogger()); // optional — injected after creation
3. Interface / Abstract Injection
The dependency defines an interface that the injector must implement. Common in strongly typed languages like Dart, Java, and C#.
// Interface Injection — Dart
abstract class Logger {
void log(String message);
}
class ConsoleLogger implements Logger {
@override
void log(String message) => print('[LOG] $message');
}
class FileLogger implements Logger {
@override
void log(String message) {
// write to file
}
}
class AuthService {
final Logger logger; // depends on the abstraction, not the implementation
AuthService(this.logger); // constructor injection
void login(String email) {
logger.log('User $email logged in');
}
}
// Usage
final auth = AuthService(ConsoleLogger()); // swap to FileLogger anytime
Dependency Injection in Dart & Flutter
Flutter does not have a built-in DI framework, but the pattern is widely used — either manually or through packages like get_it, Riverpod, or injectable.
Manual DI in Dart
// Abstract contract
abstract class AuthRepository {
Future<User> login(String email, String password);
}
// Real implementation
class FirebaseAuthRepository implements AuthRepository {
@override
Future<User> login(String email, String password) async {
// Firebase login logic
return await FirebaseAuth.instance.signInWithEmailAndPassword(
email: email,
password: password,
);
}
}
// Fake implementation for testing
class MockAuthRepository implements AuthRepository {
@override
Future<User> login(String email, String password) async {
return User(id: '123', email: email); // no Firebase needed
}
}
// Service receives the abstraction
class AuthService {
final AuthRepository repository;
AuthService(this.repository); // constructor injection
Future<User> login(String email, String password) {
return repository.login(email, password);
}
}
// Production
final service = AuthService(FirebaseAuthRepository());
// Testing
final testService = AuthService(MockAuthRepository());
DI with get_it (Service Locator)
// pubspec.yaml
// dependencies:
// get_it: ^7.6.0
import 'package:get_it/get_it.dart';
final getIt = GetIt.instance;
// Register dependencies once — usually in main.dart
void setupDependencies() {
// Register a singleton (one instance for the entire app)
getIt.registerSingleton<AuthRepository>(FirebaseAuthRepository());
// Register a lazy singleton (created only when first requested)
getIt.registerLazySingleton<AuthService>(
() => AuthService(getIt<AuthRepository>()),
);
// Register a factory (new instance every time)
getIt.registerFactory<LoginViewModel>(
() => LoginViewModel(getIt<AuthService>()),
);
}
void main() {
setupDependencies();
runApp(MyApp());
}
// Anywhere in the app — retrieve without passing through constructors
class LoginPage extends StatelessWidget {
final viewModel = getIt<LoginViewModel>();
}
DI with Riverpod
// Riverpod treats providers as the DI container
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Define the dependency
final authRepositoryProvider = Provider<AuthRepository>((ref) {
return FirebaseAuthRepository();
});
// Inject it into another provider
final authServiceProvider = Provider<AuthService>((ref) {
final repository = ref.watch(authRepositoryProvider);
return AuthService(repository); // DI via Riverpod
});
// Override for testing — swap the real implementation for a mock
void main() {
runApp(
ProviderScope(
overrides: [
authRepositoryProvider.overrideWithValue(MockAuthRepository()),
],
child: MyApp(),
),
);
}
Dependency Injection in JavaScript / TypeScript
Manual DI in JavaScript
// Define dependencies as classes or objects
class PostgresUserRepository {
async findById(id) {
return await db.query("SELECT * FROM users WHERE id = ?", [id]);
}
}
class RedisCache {
async get(key) { return await redis.get(key); }
async set(key, value) { return await redis.set(key, value); }
}
// UserService receives both — does not create either
class UserService {
constructor(userRepository, cache) {
this.userRepository = userRepository;
this.cache = cache;
}
async getUser(id) {
const cached = await this.cache.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await this.userRepository.findById(id);
await this.cache.set(`user:${id}`, JSON.stringify(user));
return user;
}
}
// Wire everything up in one place
const userService = new UserService(
new PostgresUserRepository(),
new RedisCache()
);
DI in TypeScript with Interfaces
// Define the contract with an interface
interface PaymentGateway {
charge(amount: number, currency: string): Promise<{ success: boolean; transactionId: string }>;
}
// Two implementations of the same interface
class StripeGateway implements PaymentGateway {
async charge(amount: number, currency: string) {
// Stripe API call
return { success: true, transactionId: "stripe_abc123" };
}
}
class PayPalGateway implements PaymentGateway {
async charge(amount: number, currency: string) {
// PayPal API call
return { success: true, transactionId: "paypal_xyz789" };
}
}
// OrderService depends on the interface — not on Stripe or PayPal
class OrderService {
constructor(private paymentGateway: PaymentGateway) {}
async checkout(amount: number) {
const result = await this.paymentGateway.charge(amount, "USD");
if (!result.success) throw new Error("Payment failed");
return result.transactionId;
}
}
// Swap payment providers without touching OrderService
const orderService = new OrderService(new StripeGateway());
// or:
const orderServicePayPal = new OrderService(new PayPalGateway());
Dependency Injection in Python
from abc import ABC, abstractmethod
# Abstract base class defines the contract
class NotificationService(ABC):
@abstractmethod
def send(self, recipient: str, message: str) -> None:
pass
# Two implementations
class EmailNotification(NotificationService):
def send(self, recipient: str, message: str) -> None:
print(f"Sending email to {recipient}: {message}")
class SMSNotification(NotificationService):
def send(self, recipient: str, message: str) -> None:
print(f"Sending SMS to {recipient}: {message}")
# UserService depends on the abstraction
class UserService:
def __init__(self, notification_service: NotificationService):
self.notification_service = notification_service # injected
def register(self, email: str) -> None:
# registration logic...
self.notification_service.send(email, "Welcome to the platform!")
# Production
service = UserService(EmailNotification())
service.register("user@example.com")
# Testing — inject a mock, no real emails sent
class MockNotification(NotificationService):
def __init__(self):
self.sent = []
def send(self, recipient: str, message: str) -> None:
self.sent.append((recipient, message))
mock = MockNotification()
test_service = UserService(mock)
test_service.register("test@example.com")
assert len(mock.sent) == 1 # verified without sending anything
The Real Power: Testing with Mocks
The single biggest practical benefit of DI is the ability to replace real implementations with fakes during testing. Here is a complete test example in Dart:
// The mock — returns controlled, predictable data
class MockOrderRepository implements OrderRepository {
final List<Order> _orders = [];
@override
Future<Order> create(Order order) async {
_orders.add(order);
return order.copyWith(id: 'mock-id-001');
}
@override
Future<List<Order>> findByUserId(String userId) async {
return _orders.where((o) => o.userId == userId).toList();
}
}
// The test — no database, no network, runs in milliseconds
void main() {
test('placeOrder applies VIP discount correctly', () async {
final mockRepo = MockOrderRepository();
final service = OrderService(mockRepo); // inject the mock
final order = await service.placeOrder(
userId: 'vip-user',
items: [Item(price: 100)],
isVIP: true,
);
expect(order.total, equals(85.0)); // 15% discount applied
expect(order.id, equals('mock-id-001')); // saved correctly
});
}
When to Use Each Approach
Manual DI — Small projects, full control, no overhead
Best when: few dependencies, simple structure
Service Locator — Medium projects, centralized registry (get_it)
Best when: deep widget trees, avoid prop drilling
DI Framework — Large projects, auto-wiring (injectable, Spring, .NET)
Best when: many dependencies, large teams, enterprise scale
Interface-based — Always recommended alongside any DI approach
Best when: you want to swap implementations or write tests
Final Thought
Dependency Injection is not a framework feature or a language trick — it is a mindset. The moment you stop asking "how does this class get what it needs?" and start asking "who should be responsible for providing it?", your architecture becomes cleaner, your tests become faster, and your code becomes genuinely maintainable.
Start with constructor injection. Define your dependencies as abstractions. Wire everything in one place. That is all DI is — and it is enough to transform how you build software.


