D Dependency Inversion Principle DIP · How it works
1 min readRapid overview
- How it works
- **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
How it works
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.