Front-end Engineering Lab
PatternsArchitecture Patterns

Dependency Injection

Provide dependencies from outside instead of creating them internally

Dependency Injection (DI)

Dependency Injection is a pattern where objects receive their dependencies from external sources rather than creating them internally. Essential for testable, modular code.

🎯 The Problem

// ❌ BAD: Hard-coded dependencies
class UserService {
  private api = new API('https://api.example.com');  // Hard-coded!
  private logger = new Logger();                     // Can't swap!
  
  async getUser(id: string) {
    this.logger.log(`Fetching user ${id}`);
    return this.api.get(`/users/${id}`);
  }
}

// Problems:
// - Can't test (API is real)
// - Can't change API URL
// - Can't swap logger implementation
// - Tightly coupled

// ✅ GOOD: Dependencies injected
class UserService {
  constructor(
    private api: API,      // Injected!
    private logger: Logger // Injected!
  ) {}
  
  async getUser(id: string) {
    this.logger.log(`Fetching user ${id}`);
    return this.api.get(`/users/${id}`);
  }
}

// Now you can:
const realService = new UserService(realAPI, consoleLogger);
const testService = new UserService(mockAPI, noOpLogger);

📊 DI Methods Comparison

MethodFlexibilityTestabilityComplexity
Constructor⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Property⭐⭐⭐⭐⭐⭐⭐⭐
Method⭐⭐⭐⭐⭐⭐⭐⭐
React Context⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Container⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

🔧 DI Methods

interface Logger {
  log(message: string): void;
  error(message: string): void;
}

interface API {
  get<T>(url: string): Promise<T>;
  post<T>(url: string, data: unknown): Promise<T>;
}

class UserService {
  constructor(
    private readonly api: API,
    private readonly logger: Logger
  ) {}
  
  async getUser(id: string): Promise<User> {
    try {
      this.logger.log(`Fetching user ${id}`);
      return await this.api.get<User>(`/users/${id}`);
    } catch (error) {
      this.logger.error(`Failed to fetch user: ${error}`);
      throw error;
    }
  }
}

// Production
const userService = new UserService(
  new RealAPI('https://api.example.com'),
  new ConsoleLogger()
);

// Testing
const mockAPI = {
  get: jest.fn().mockResolvedValue({ id: '1', name: 'John' })
};
const mockLogger = {
  log: jest.fn(),
  error: jest.fn()
};
const testService = new UserService(mockAPI, mockLogger);

2. Property Injection

class UserService {
  public api!: API;
  public logger!: Logger;
  
  async getUser(id: string): Promise<User> {
    this.logger.log(`Fetching user ${id}`);
    return this.api.get<User>(`/users/${id}`);
  }
}

// Usage
const service = new UserService();
service.api = realAPI;
service.logger = consoleLogger;

3. Method Injection

class UserService {
  async getUser(
    id: string,
    api: API,
    logger: Logger
  ): Promise<User> {
    logger.log(`Fetching user ${id}`);
    return api.get<User>(`/users/${id}`);
  }
}

// Usage
const service = new UserService();
await service.getUser('123', realAPI, consoleLogger);

⚛️ DI in React

1. Context-Based DI

// Define services
interface Services {
  api: API;
  logger: Logger;
  analytics: Analytics;
}

// Create context
const ServicesContext = createContext<Services | null>(null);

// Provider
function ServicesProvider({ children }: { children: React.ReactNode }) {
  const services = useMemo<Services>(() => ({
    api: new RealAPI(process.env.REACT_APP_API_URL!),
    logger: new ConsoleLogger(),
    analytics: new GoogleAnalytics()
  }), []);
  
  return (
    <ServicesContext.Provider value={services}>
      {children}
    </ServicesContext.Provider>
  );
}

// Custom hook for DI
function useServices(): Services {
  const services = useContext(ServicesContext);
  
  if (!services) {
    throw new Error('useServices must be used within ServicesProvider');
  }
  
  return services;
}

// Individual service hooks
function useAPI() {
  return useServices().api;
}

function useLogger() {
  return useServices().logger;
}

function useAnalytics() {
  return useServices().analytics;
}

// Usage in component
function UserProfile({ userId }: { userId: string }) {
  const api = useAPI();
  const logger = useLogger();
  const [user, setUser] = useState<User | null>(null);
  
  useEffect(() => {
    logger.log(`Loading user ${userId}`);
    api.get<User>(`/users/${userId}`).then(setUser);
  }, [userId, api, logger]);
  
  return <div>{user?.name}</div>;
}

// Testing
test('UserProfile', () => {
  const mockServices = {
    api: { get: jest.fn().mockResolvedValue({ name: 'John' }) },
    logger: { log: jest.fn(), error: jest.fn() },
    analytics: { track: jest.fn() }
  };
  
  render(
    <ServicesContext.Provider value={mockServices}>
      <UserProfile userId="123" />
    </ServicesContext.Provider>
  );
  
  // Easy to test with mocks!
});

2. Props-Based DI (Simpler)

interface UserProfileProps {
  userId: string;
  api: API;
  logger: Logger;
}

function UserProfile({ userId, api, logger }: UserProfileProps) {
  const [user, setUser] = useState<User | null>(null);
  
  useEffect(() => {
    logger.log(`Loading user ${userId}`);
    api.get<User>(`/users/${userId}`).then(setUser);
  }, [userId, api, logger]);
  
  return <div>{user?.name}</div>;
}

// Usage
<UserProfile 
  userId="123" 
  api={realAPI} 
  logger={consoleLogger} 
/>

// Testing
<UserProfile 
  userId="123" 
  api={mockAPI} 
  logger={mockLogger} 
/>

🏗️ DI Container

InversifyJS Style

import 'reflect-metadata';
import { Container, injectable, inject } from 'inversify';

// Define identifiers
const TYPES = {
  API: Symbol.for('API'),
  Logger: Symbol.for('Logger'),
  UserService: Symbol.for('UserService')
};

// Mark classes as injectable
@injectable()
class ConsoleLogger implements Logger {
  log(message: string) {
    console.log(message);
  }
}

@injectable()
class RealAPI implements API {
  constructor(@inject(TYPES.Logger) private logger: Logger) {}
  
  async get<T>(url: string): Promise<T> {
    this.logger.log(`GET ${url}`);
    const response = await fetch(url);
    return response.json();
  }
}

@injectable()
class UserService {
  constructor(
    @inject(TYPES.API) private api: API,
    @inject(TYPES.Logger) private logger: Logger
  ) {}
  
  async getUser(id: string): Promise<User> {
    this.logger.log(`Fetching user ${id}`);
    return this.api.get<User>(`/users/${id}`);
  }
}

// Setup container
const container = new Container();
container.bind<Logger>(TYPES.Logger).to(ConsoleLogger).inSingletonScope();
container.bind<API>(TYPES.API).to(RealAPI).inSingletonScope();
container.bind<UserService>(TYPES.UserService).to(UserService);

// Get services (dependencies resolved automatically!)
const userService = container.get<UserService>(TYPES.UserService);

Simple Custom Container

type ServiceFactory<T> = (container: DIContainer) => T;

class DIContainer {
  private services = new Map<string, unknown>();
  private factories = new Map<string, ServiceFactory<unknown>>();
  private singletons = new Set<string>();
  
  register<T>(
    name: string,
    factory: ServiceFactory<T>,
    singleton: boolean = false
  ): void {
    this.factories.set(name, factory);
    if (singleton) {
      this.singletons.add(name);
    }
  }
  
  get<T>(name: string): T {
    // Return singleton if already created
    if (this.services.has(name)) {
      return this.services.get(name);
    }
    
    // Get factory
    const factory = this.factories.get(name);
    if (!factory) {
      throw new Error(`Service "${name}" not registered`);
    }
    
    // Create instance
    const instance = factory(this);
    
    // Store if singleton
    if (this.singletons.has(name)) {
      this.services.set(name, instance);
    }
    
    return instance;
  }
}

// Usage
const container = new DIContainer();

container.register('logger', () => new ConsoleLogger(), true);

container.register('api', (c) => 
  new RealAPI(c.get('logger')), 
  true
);

container.register('userService', (c) =>
  new UserService(c.get('api'), c.get('logger'))
);

// Dependencies resolved automatically!
const userService = container.get<UserService>('userService');

🎯 Practical Example: API Client

// Interfaces
interface HTTPClient {
  get<T>(url: string, options?: RequestOptions): Promise<T>;
  post<T>(url: string, data: unknown, options?: RequestOptions): Promise<T>;
  put<T>(url: string, data: unknown, options?: RequestOptions): Promise<T>;
  delete<T>(url: string, options?: RequestOptions): Promise<T>;
}

interface AuthService {
  getToken(): string | null;
  refreshToken(): Promise<string>;
}

interface Logger {
  log(message: string): void;
  error(message: string): void;
}

// Implementations
class FetchClient implements HTTPClient {
  constructor(
    private baseURL: string,
    private authService: AuthService,
    private logger: Logger
  ) {}
  
  async get<T>(url: string): Promise<T> {
    const token = this.authService.getToken();
    
    this.logger.log(`GET ${this.baseURL}${url}`);
    
    const response = await fetch(`${this.baseURL}${url}`, {
      headers: {
        'Authorization': `Bearer ${token}`
      }
    });
    
    if (!response.ok) {
      this.logger.error(`GET ${url} failed: ${response.status}`);
      throw new Error(`HTTP ${response.status}`);
    }
    
    return response.json();
  }
  
  // ... other methods
}

class TokenAuthService implements AuthService {
  constructor(private storage: Storage) {}
  
  getToken(): string | null {
    return this.storage.getItem('token');
  }
  
  async refreshToken(): Promise<string> {
    // Refresh logic
    return 'new-token';
  }
}

// Setup
const logger = new ConsoleLogger();
const authService = new TokenAuthService(localStorage);
const httpClient = new FetchClient(
  'https://api.example.com',
  authService,
  logger
);

// Use in services
class UserRepository {
  constructor(private http: HTTPClient) {}
  
  async getUser(id: string): Promise<User> {
    return this.http.get<User>(`/users/${id}`);
  }
}

const userRepository = new UserRepository(httpClient);

🧪 Testing Benefits

// Easy to test with DI!
describe('UserService', () => {
  it('fetches user', async () => {
    // Create mocks
    const mockAPI = {
      get: jest.fn().mockResolvedValue({
        id: '1',
        name: 'John'
      })
    };
    
    const mockLogger = {
      log: jest.fn(),
      error: jest.fn()
    };
    
    // Inject mocks
    const service = new UserService(mockAPI as API, mockLogger as Logger);
    
    // Test
    const user = await service.getUser('1');
    
    expect(user.name).toBe('John');
    expect(mockLogger.log).toHaveBeenCalledWith('Fetching user 1');
    expect(mockAPI.get).toHaveBeenCalledWith('/users/1');
  });
});

📚 Key Takeaways

  1. Constructor injection - Preferred method
  2. Interfaces over implementations - Depend on abstractions
  3. Single Responsibility - Each class does one thing
  4. Testability - Easy to mock dependencies
  5. Flexibility - Swap implementations easily
  6. React Context - Natural DI in React
  7. Container for complex apps - Auto-resolve dependencies

Rule of thumb: If you're using new inside a class, consider DI instead.

On this page