I Interface Segregation Principle ISP
8 min readI — Interface Segregation Principle (ISP)
"Clients should not be forced to depend on methods they do not use."
Split large interfaces into smaller, focused ones so implementations only need to provide what's actually used.
❌ Bad example (React/TypeScript):
// Fat interface forces all implementations to have all methods
interface MediaPlayer {
play(): void;
pause(): void;
stop(): void;
seekTo(time: number): void;
setVolume(level: number): void;
toggleFullscreen(): void;
enableSubtitles(): void;
adjustPlaybackSpeed(speed: number): void;
}
// Audio player doesn't need fullscreen or subtitles
class AudioPlayer implements MediaPlayer {
play() { /* ... */ }
pause() { /* ... */ }
stop() { /* ... */ }
seekTo(time: number) { /* ... */ }
setVolume(level: number) { /* ... */ }
// Forced to implement unnecessary methods
toggleFullscreen() {
throw new Error('Not supported'); // ❌
}
enableSubtitles() {
throw new Error('Not supported'); // ❌
}
adjustPlaybackSpeed(speed: number) {
throw new Error('Not supported'); // ❌
}
}
Each implementation is forced to implement everything, even if it doesn't need to. Violates both ISP and LSP.
✅ Good example (React/TypeScript):
// Split into focused interfaces
interface Playable {
play(): void;
pause(): void;
stop(): void;
}
interface Seekable {
seekTo(time: number): void;
}
interface VolumeControl {
setVolume(level: number): void;
}
interface VideoControls {
toggleFullscreen(): void;
enableSubtitles(): void;
}
interface PlaybackSpeedControl {
adjustPlaybackSpeed(speed: number): void;
}
// Audio player implements only what it needs
class AudioPlayer implements Playable, Seekable, VolumeControl, PlaybackSpeedControl {
play() { /* ... */ }
pause() { /* ... */ }
stop() { /* ... */ }
seekTo(time: number) { /* ... */ }
setVolume(level: number) { /* ... */ }
adjustPlaybackSpeed(speed: number) { /* ... */ }
}
// Video player implements all controls
class VideoPlayer implements Playable, Seekable, VolumeControl, VideoControls, PlaybackSpeedControl {
play() { /* ... */ }
pause() { /* ... */ }
stop() { /* ... */ }
seekTo(time: number) { /* ... */ }
setVolume(level: number) { /* ... */ }
toggleFullscreen() { /* ... */ }
enableSubtitles() { /* ... */ }
adjustPlaybackSpeed(speed: number) { /* ... */ }
}
// Simple player with minimal controls
class SimplePlayer implements Playable {
play() { /* ... */ }
pause() { /* ... */ }
stop() { /* ... */ }
}
// Components depend only on what they need
function PlayControls({ player }: { player: Playable }) {
return (
<div>
<button onClick={() => player.play()}>Play</button>
<button onClick={() => player.pause()}>Pause</button>
<button onClick={() => player.stop()}>Stop</button>
</div>
);
}
function VolumeControls({ player }: { player: VolumeControl }) {
return <input type="range" onChange={(e) => player.setVolume(+e.target.value)} />;
}
💡 Now each player only implements what it actually supports, and components depend on minimal interfaces.
✅ Good example (Angular services):
// Bad - fat service interface
interface DataService {
fetch(): Observable<Data[]>;
create(data: Data): Observable<Data>;
update(id: string, data: Data): Observable<Data>;
delete(id: string): Observable<void>;
export(): Observable<Blob>;
import(file: File): Observable<void>;
subscribe(callback: (data: Data[]) => void): void;
unsubscribe(): void;
}
// Good - segregated interfaces
interface ReadableDataService {
fetch(): Observable<Data[]>;
}
interface WritableDataService {
create(data: Data): Observable<Data>;
update(id: string, data: Data): Observable<Data>;
delete(id: string): Observable<void>;
}
interface ExportableDataService {
export(): Observable<Blob>;
import(file: File): Observable<void>;
}
interface RealtimeDataService {
subscribe(callback: (data: Data[]) => void): void;
unsubscribe(): void;
}
// Implementations choose what they support
@Injectable()
export class ApiDataService implements ReadableDataService, WritableDataService {
constructor(private http: HttpClient) {}
fetch(): Observable<Data[]> {
return this.http.get<Data[]>('/api/data');
}
create(data: Data): Observable<Data> {
return this.http.post<Data>('/api/data', data);
}
update(id: string, data: Data): Observable<Data> {
return this.http.put<Data>(`/api/data/${id}`, data);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`/api/data/${id}`);
}
}
@Injectable()
export class ReadOnlyDataService implements ReadableDataService {
constructor(private http: HttpClient) {}
fetch(): Observable<Data[]> {
return this.http.get<Data[]>('/api/data');
}
}
// Components depend only on what they need
@Component({...})
export class DataListComponent {
constructor(private dataService: ReadableDataService) {
// Only needs read access
}
ngOnInit() {
this.dataService.fetch().subscribe(data => this.data = data);
}
}
@Component({...})
export class DataEditorComponent {
constructor(
private readService: ReadableDataService,
private writeService: WritableDataService
) {
// Needs both read and write
}
}
✅ Good example (React form handling):
// Bad - single fat interface
interface FormHandler {
validate(): ValidationErrors;
submit(): Promise<void>;
reset(): void;
autosave(): void;
export(): string;
import(data: string): void;
}
// Good - segregated by responsibility
interface Validatable {
validate(): ValidationErrors;
}
interface Submittable {
submit(): Promise<void>;
}
interface Resetable {
reset(): void;
}
interface Autosaveable {
autosave(): void;
}
interface Exportable {
export(): string;
import(data: string): void;
}
// Simple form only needs basic operations
class ContactForm implements Validatable, Submittable, Resetable {
validate(): ValidationErrors {
// Validation logic
return {};
}
submit(): Promise<void> {
// Submit logic
return Promise.resolve();
}
reset(): void {
// Reset logic
}
}
// Complex form needs all features
class SurveyForm implements Validatable, Submittable, Resetable, Autosaveable, Exportable {
validate(): ValidationErrors { return {}; }
submit(): Promise<void> { return Promise.resolve(); }
reset(): void {}
autosave(): void {}
export(): string { return '{}'; }
import(data: string): void {}
}
// Components use focused interfaces
function FormValidator({ form }: { form: Validatable }) {
const errors = form.validate();
return <ErrorDisplay errors={errors} />;
}
function SubmitButton({ form }: { form: Submittable }) {
return <button onClick={() => form.submit()}>Submit</button>;
}
function AutosaveIndicator({ form }: { form: Autosaveable }) {
useEffect(() => {
const interval = setInterval(() => form.autosave(), 30000);
return () => clearInterval(interval);
}, [form]);
return <span>Autosaving...</span>;
}
✅ Good example (Event system):
// Bad - single notification interface
interface NotificationService {
sendEmail(to: string, message: string): void;
sendSMS(to: string, message: string): void;
sendPush(to: string, message: string): void;
logEvent(event: string): void;
}
// Good - segregated by channel
interface EmailNotifier {
sendEmail(to: string, message: string): void;
}
interface SMSNotifier {
sendSMS(to: string, message: string): void;
}
interface PushNotifier {
sendPush(to: string, message: string): void;
}
interface EventLogger {
logEvent(event: string): void;
}
// Services implement what they support
class EmailService implements EmailNotifier {
sendEmail(to: string, message: string): void {
// Email implementation
}
}
class SMSService implements SMSNotifier {
sendSMS(to: string, message: string): void {
// SMS implementation
}
}
class FullNotificationService implements EmailNotifier, SMSNotifier, PushNotifier, EventLogger {
sendEmail(to: string, message: string): void {}
sendSMS(to: string, message: string): void {}
sendPush(to: string, message: string): void {}
logEvent(event: string): void {}
}
// Consumers depend only on what they need
function useEmailNotifications(notifier: EmailNotifier) {
const notify = (to: string, msg: string) => {
notifier.sendEmail(to, msg);
};
return { notify };
}
💡 In frontend applications:
- Component props: Don't force components to accept props they don't use. Split large prop interfaces.
- Services: Separate read/write, sync/async, and different feature concerns into focused interfaces.
- Hooks: Custom hooks should accept minimal dependencies, not fat service objects.
- State management: Actions/reducers for different domains should use separate interfaces.
- API clients: Split into focused clients (UserAPI, ProductAPI) instead of one massive API interface.
Questions & Answers
A: Components with minimal prop requirements can be used in more contexts. A <Button onClick={...}> is more reusable than <Button form={formObject}> that requires a fat form interface.
- Accept props they never use
- Pass entire objects when they only need one or two properties
- Implement optional methods that throw "not supported" errors
- Have
anyor overly broad types to avoid interface constraints
A: Components that:
A: Custom hooks should accept focused dependencies:
// Bad - hook requires entire user service
function useUserName(userService: UserService) {
// Only uses getName method
}
// Good - hook requires only what it needs
function useUserName(getName: (id: string) => string) {
// Minimal dependency
}A: Inject only the interface you need. Angular allows providing multiple interfaces with the same implementation:
providers: [
UserService,
{ provide: ReadableUserService, useExisting: UserService },
{ provide: WritableUserService, useExisting: UserService }
]
// Components inject what they need
constructor(private users: ReadableUserService) {}- Change together (all CRUD operations might stay together)
- Serve the same use case
- Have similar client needs
A: Segregate by cohesive responsibilities, not per method. Group operations that:
Don't create one interface per method unless methods truly serve different concerns.
A: Smaller interfaces mean simpler mocks:
// Easy to mock
const mockPlayable: Playable = {
play: jest.fn(),
pause: jest.fn(),
stop: jest.fn()
};
// vs mocking a 20-method interfaceA: Yes. Too many tiny interfaces create noise:
// Over-segregated
interface Clickable { onClick(): void; }
interface Hoverable { onHover(): void; }
interface Focusable { onFocus(): void; }
// Better - cohesive interaction interface
interface Interactive {
onClick(): void;
onHover(): void;
onFocus(): void;
}
Balance between focused and practical.
A: Split large contexts into focused ones:
// Bad - single fat context
const AppContext = createContext({
user, setUser,
theme, setTheme,
notifications, addNotification,
settings, updateSettings
});
// Good - segregated contexts
const UserContext = createContext({ user, setUser });
const ThemeContext = createContext({ theme, setTheme });
const NotificationContext = createContext({ notifications, addNotification });
const SettingsContext = createContext({ settings, updateSettings });
// Components consume only what they need
function Avatar() {
const { user } = useContext(UserContext); // Only user context
return <img src={user.avatar} />;
}A: Create focused selectors and action creators instead of exposing the entire store:
// Bad - component depends on entire state shape
function UserProfile({ state }: { state: AppState }) {
return <div>{state.user.profile.name}</div>;
}
// Good - component depends on minimal data
function UserProfile({ userName }: { userName: string }) {
return <div>{userName}</div>;
}
// Container uses focused selector
const mapStateToProps = (state: AppState) => ({
userName: selectUserName(state) // Minimal interface
});A: Segregated interfaces enable better tree-shaking. Import only the interfaces/implementations you need:
// Can import and use AudioPlayer without pulling in video codec dependencies
import { AudioPlayer } from '@/players';- Interfaces have single, clear purposes
- Components don't accept props they never use
- Services don't implement methods that throw "not supported"
- Type definitions match actual usage patterns
- No large "god objects" passed around
A: Check that: