Engineering
2026-04-14
15 min read

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.

Dependency Injection Explained: From Concept to Production Code

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.

Share
Deephang Thegim

Deephang Thegim

Your Friendly Neighborhood