D Dependency Inversion Principle DIP
9 min read- **D — Dependency Inversion Principle (DIP)**
- ❌ Bad example (React - high-level depends on low-level)
- ✅ Good example (React - depend on abstractions)
- ✅ Good example (React with custom hooks)
- ✅ Good example (Angular with dependency injection)
- ✅ Good example (React Context for DI)
- Notes on usage and patterns
- DIP benefits in frontend
- Questions & Answers
D — Dependency Inversion Principle (DIP)
"High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions."
The Dependency Inversion Principle shifts coupling away from concrete implementations toward stable abstractions (interfaces or abstract classes). This reduces the ripple effect when implementation details change and makes code easier to test and extend.
❌ Bad example (React - high-level depends on low-level)
// Low-level implementation detail
class LocalStorageService {
save(key: string, value: string): void {
localStorage.setItem(key, value);
}
load(key: string): string | null {
return localStorage.getItem(key);
}
}
// High-level component directly depends on concrete implementation
function UserPreferences() {
const storage = new LocalStorageService(); // ❌ Direct dependency on concrete class
const saveTheme = (theme: string) => {
storage.save('theme', theme);
};
const loadTheme = (): string | null => {
return storage.load('theme');
};
return <div>...</div>;
}
Problems: Component is tightly coupled to LocalStorageService. You cannot easily replace storage (e.g., with sessionStorage for tests or API for cloud sync) without changing the component.
✅ Good example (React - depend on abstractions)
// Abstraction (high-level interface)
interface StorageService {
save(key: string, value: string): void;
load(key: string): string | null;
}
// Low-level implementations depend on abstraction
class LocalStorageService implements StorageService {
save(key: string, value: string): void {
localStorage.setItem(key, value);
}
load(key: string): string | null {
return localStorage.getItem(key);
}
}
class SessionStorageService implements StorageService {
save(key: string, value: string): void {
sessionStorage.setItem(key, value);
}
load(key: string): string | null {
return sessionStorage.getItem(key);
}
}
class ApiStorageService implements StorageService {
async save(key: string, value: string): Promise<void> {
await fetch('/api/preferences', {
method: 'POST',
body: JSON.stringify({ key, value })
});
}
async load(key: string): Promise<string | null> {
const response = await fetch(`/api/preferences/${key}`);
return response.json();
}
}
// High-level component depends on abstraction via injection
function UserPreferences({ storage }: { storage: StorageService }) {
const saveTheme = (theme: string) => {
storage.save('theme', theme);
};
const loadTheme = (): string | null => {
return storage.load('theme');
};
return <div>...</div>;
}
// Composition root decides which implementation to use
function App() {
const storage = new LocalStorageService(); // or SessionStorageService, ApiStorageService
return <UserPreferences storage={storage} />;
}
Now UserPreferences depends only on StorageService interface. You can provide any implementation without changing the component.
✅ Good example (React with custom hooks)
// Abstraction
interface UserRepository {
getUser(id: string): Promise<User>;
updateUser(id: string, data: Partial<User>): Promise<User>;
}
// Implementations
class ApiUserRepository implements UserRepository {
async getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
async updateUser(id: string, data: Partial<User>): Promise<User> {
const response = await fetch(`/api/users/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
});
return response.json();
}
}
class MockUserRepository implements UserRepository {
private users = new Map<string, User>();
async getUser(id: string): Promise<User> {
return this.users.get(id) ?? mockUser;
}
async updateUser(id: string, data: Partial<User>): Promise<User> {
const user = { ...this.users.get(id), ...data };
this.users.set(id, user);
return user;
}
}
// Custom hook depends on abstraction
function useUser(repository: UserRepository, userId: string) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
repository.getUser(userId).then(data => {
setUser(data);
setLoading(false);
});
}, [repository, userId]);
const updateUser = async (data: Partial<User>) => {
const updated = await repository.updateUser(userId, data);
setUser(updated);
};
return { user, loading, updateUser };
}
// Component receives dependency
function UserProfile({ repository, userId }: { repository: UserRepository; userId: string }) {
const { user, loading, updateUser } = useUser(repository, userId);
if (loading) return <Spinner />;
return <UserForm user={user} onSubmit={updateUser} />;
}
// Composition root
function App() {
const userRepository = new ApiUserRepository(); // or MockUserRepository for tests
return <UserProfile repository={userRepository} userId="123" />;
}
✅ Good example (Angular with dependency injection)
// Abstraction
export abstract class DataService {
abstract fetchData(): Observable<Data[]>;
abstract saveData(data: Data): Observable<Data>;
}
// Low-level implementations
@Injectable()
export class HttpDataService implements DataService {
constructor(private http: HttpClient) {}
fetchData(): Observable<Data[]> {
return this.http.get<Data[]>('/api/data');
}
saveData(data: Data): Observable<Data> {
return this.http.post<Data>('/api/data', data);
}
}
@Injectable()
export class MockDataService implements DataService {
fetchData(): Observable<Data[]> {
return of([mockData1, mockData2]);
}
saveData(data: Data): Observable<Data> {
return of(data);
}
}
// High-level component depends on abstraction
@Component({
selector: 'app-data-list',
template: `
<div *ngFor="let item of data$ | async">
{{ item.name }}
</div>
`
})
export class DataListComponent implements OnInit {
data$!: Observable<Data[]>;
// Inject abstraction, not concrete class
constructor(private dataService: DataService) {}
ngOnInit() {
this.data$ = this.dataService.fetchData();
}
}
// app.module.ts - composition root decides implementation
@NgModule({
providers: [
{ provide: DataService, useClass: HttpDataService } // or MockDataService for testing
]
})
export class AppModule {}
✅ Good example (React Context for DI)
// Abstraction
interface AuthService {
login(email: string, password: string): Promise<User>;
logout(): Promise<void>;
getCurrentUser(): User | null;
}
// Implementations
class FirebaseAuthService implements AuthService {
async login(email: string, password: string): Promise<User> {
// Firebase auth logic
return user;
}
async logout(): Promise<void> {
// Firebase logout
}
getCurrentUser(): User | null {
// Get current user from Firebase
return null;
}
}
class Auth0Service implements AuthService {
async login(email: string, password: string): Promise<User> {
// Auth0 logic
return user;
}
async logout(): Promise<void> {
// Auth0 logout
}
getCurrentUser(): User | null {
// Get current user from Auth0
return null;
}
}
// Dependency injection via Context
const AuthContext = createContext<AuthService | null>(null);
export function AuthProvider({ service, children }: { service: AuthService; children: ReactNode }) {
return <AuthContext.Provider value={service}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const service = useContext(AuthContext);
if (!service) throw new Error('useAuth must be used within AuthProvider');
return service;
}
// Components depend on abstraction via hook
function LoginForm() {
const authService = useAuth(); // Injected abstraction
const handleSubmit = async (email: string, password: string) => {
await authService.login(email, password);
};
return <form onSubmit={...}>...</form>;
}
// Composition root provides implementation
function App() {
const authService = new FirebaseAuthService(); // or Auth0Service
return (
<AuthProvider service={authService}>
<LoginForm />
</AuthProvider>
);
}
Notes on usage and patterns
- Prefer constructor injection / prop injection for mandatory dependencies — it makes required collaborators explicit and easy to test.
- Use interfaces or abstract classes to define stable contracts for behavior. Keep these contracts small and focused.
- Context/DI containers can wire concrete implementations to abstractions at composition root, keeping production wiring out of business logic.
- Avoid service locators embedded inside components — they hide dependencies and complicate testing.
DIP benefits in frontend
- Decouples high-level components/hooks from low-level implementation details.
- Makes unit testing trivial by allowing replacement with fakes/mocks.
- Improves flexibility to change implementations (API clients, storage mechanisms, auth providers) without touching business code.
- Enables feature flags and A/B testing by swapping implementations at runtime.
Questions & Answers
A: DIP is the principle (depend on abstractions). Dependency injection is a technique to supply those abstractions at runtime. You can inject dependencies via props, hooks, or Context, but DIP guides the design to use interfaces/abstractions.
A: It's typically the top-level App component or a provider tree where concrete implementations are created and injected. Keeping all bindings there ensures the rest of the system depends only on abstractions, honoring DIP.
A: You can swap real implementations with mocks when components depend on interfaces:
// Production
<UserProfile repository={new ApiUserRepository()} />
// Tests
<UserProfile repository={new MockUserRepository()} />
Tests stay fast and deterministic.
- You need to swap implementations
- Testing requires mocks
- Multiple implementations exist
A: If there's only one implementation with no foreseeable variation, an interface may add noise. Start with concrete classes and extract interfaces when:
A: Custom hooks should accept dependencies rather than creating them:
// Bad - hook creates concrete dependency
function useUser(userId: string) {
const api = new ApiClient(); // ❌ Hardcoded
}
// Good - hook receives abstraction
function useUser(api: UserApi, userId: string) {
// Can inject MockUserApi for tests
}A: Define them in separate files/modules and keep them small:
// interfaces/storage.ts - stable abstraction
export interface StorageService {
save(key: string, value: string): void;
load(key: string): string | null;
}
// implementations/localStorage.ts - concrete detail
import { StorageService } from '../interfaces/storage';
export class LocalStorageService implements StorageService { ... }A: It couples components to specific implementations:
// Bad - couples to Firebase
const user = useContext(FirebaseAuthContext);
// Good - couples to abstraction
const authService = useContext(AuthServiceContext); // Can be any AuthServiceA: Create typed contexts for interfaces, not concrete classes:
const ApiContext = createContext<ApiClient | null>(null);
// Provide concrete implementation
<ApiContext.Provider value={new HttpApiClient()}>
<App />
</ApiContext.Provider>
// Components consume abstraction
const api = useContext(ApiContext);A: You can conditionally provide different implementations:
const authService = useFeatureFlag('new-auth')
? new Auth0Service()
: new FirebaseAuthService();
<AuthProvider service={authService}>
<App />
</AuthProvider>A: Use abstract classes as DI tokens:
// Abstract token
export abstract class StorageService {
abstract save(key: string, value: string): void;
abstract load(key: string): string | null;
}
// Provide implementation
providers: [
{ provide: StorageService, useClass: LocalStorageService }
]
// Component injects abstraction
constructor(private storage: StorageService) {}A: Define action creators and selectors as interfaces, inject store implementation:
interface UserStore {
getUser(id: string): User | null;
updateUser(id: string, data: Partial<User>): void;
}
// Redux implementation
class ReduxUserStore implements UserStore { ... }
// Zustand implementation
class ZustandUserStore implements UserStore { ... }
// Components depend on interface
function UserProfile({ store }: { store: UserStore }) { ... }A: Inject different implementations based on environment:
const apiClient = process.env.NODE_ENV === 'production'
? new ProductionApiClient()
: new MockApiClient();
<ApiProvider client={apiClient}>
<App />
</ApiProvider>A: Testing Library encourages testing behavior over implementation. DIP supports this by allowing you to inject test doubles while keeping component logic unchanged:
render(<Component api={mockApi} />); // Inject mock via DI
// Test behavior, not implementation details- Single use case → Use concrete class directly
- Need tests → Extract interface, inject via props
- Multiple implementations → Full DIP with Context/providers
A: Start simple, add abstractions when needed:
Don't over-abstract prematurely.