D Dependency Inversion Principle DIP · Quick recall Q&A
2 min readQuick recall Q&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.
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.
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
If there's only one implementation with no foreseeable variation, an interface may add noise. Start with concrete classes and extract interfaces when:
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
}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 { ... }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 AuthServiceCreate 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);You can conditionally provide different implementations:
const authService = useFeatureFlag('new-auth')
? new Auth0Service()
: new FirebaseAuthService();
<AuthProvider service={authService}>
<App />
</AuthProvider>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) {}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 }) { ... }Inject different implementations based on environment:
const apiClient = process.env.NODE_ENV === 'production'
? new ProductionApiClient()
: new MockApiClient();
<ApiProvider client={apiClient}>
<App />
</ApiProvider>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
Start simple, add abstractions when needed:
Don't over-abstract prematurely.