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
| Method | Flexibility | Testability | Complexity |
|---|---|---|---|
| Constructor | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ |
| Property | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐ |
| Method | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
| React Context | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| Container | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
🔧 DI Methods
1. Constructor Injection (Recommended)
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
- Constructor injection - Preferred method
- Interfaces over implementations - Depend on abstractions
- Single Responsibility - Each class does one thing
- Testability - Easy to mock dependencies
- Flexibility - Swap implementations easily
- React Context - Natural DI in React
- Container for complex apps - Auto-resolve dependencies
Rule of thumb: If you're using new inside a class, consider DI instead.