Stop Forcing Your SDK Consumers to Implement Methods They’ll Never Use
Part 3 of the SOLID SDK Series. Catch up on Part 1 (SRP, OCP, DIP) and Part 2 (LSP) before diving in.
Stop Forcing Your SDK Consumers to Implement Methods They’ll Never Use
Part 3 of the SOLID SDK Series. Catch up on Part 1 (SRP, OCP, DIP) and Part 2 (LSP) before diving in.
Here’s a situation every SDK author eventually creates — usually without realizing it.
You design a clean, powerful interface. It covers everything your feature needs. You’re proud of it. Then a consumer opens a GitHub issue: “Why do I have to implement onRetry, onTimeout, onRateLimit, and onCircuitBreak just to use basic logging?"
You’ve built a fat interface. And it’s quietly punishing everyone who uses your SDK.
“Clients should not be forced to depend on interfaces they do not use.” — Robert C. Martin
That’s the Interface Segregation Principle. And it’s the difference between an interface that feels like a gift and one that feels like a tax.
What is ISP?
The Interface Segregation Principle says:
No client should be forced to implement methods it doesn’t need. Prefer many small, focused interfaces over one large general-purpose one.
It’s the SRP applied to interfaces. Just as a class should have one reason to change, an interface should serve one focused purpose — and only that purpose.
When an interface does too much, every implementor is forced to provide stubs, throw NotImplementedError, or return dummy values for functionality they'll never use. That's friction. In an SDK, friction is the enemy.
What a fat interface looks like
When building my SDK’s event system, I started with this:
// ❌ The "kitchen sink" interface
interface SdkEventHandler {
onRequest(req: Request): void;
onResponse(res: Response): void;
onError(err: Error): void;
onRetry(attempt: number): void;
onRateLimit(retryAfter: number): void;
onCacheHit(key: string): void;
onCacheMiss(key: string): void;
onAuthRefresh(token: Token): void;
onCircuitBreak(): void;
}
Now imagine a consumer who just wants to log errors:
// Consumer is forced to implement 8 methods they don't care about
class ErrorLogger implements SdkEventHandler {
onRequest(_req: Request): void {} // stub
onResponse(_res: Response): void {} // stub
onError(err: Error): void { console.error(err); } // the only one they wanted
onRetry(_attempt: number): void {} // stub
onRateLimit(_retryAfter: number): void {} // stub
onCacheHit(_key: string): void {} // stub
onCacheMiss(_key: string): void {} // stub
onAuthRefresh(_token: Token): void {} // stub
onCircuitBreak(): void {} // stub
}
Eight stubs for one line of real code. That’s the ISP tax.
Worse — every time you add a new event to SdkEventHandler, you break every existing implementation. Even the ones that don't care about the new event. Consumers now have to update their code for a feature they'll never use.
The ISP fix — focused, composable interfaces
The solution is to decompose the fat interface into focused ones that clients opt into:
// ✅ Small, focused interfaces — implement only what you need
interface RequestEventHandler {
onRequest(req: Request): void;
onResponse(res: Response): void;
}
interface ErrorEventHandler {
onError(err: Error): void;
onRetry(attempt: number): void;
}
interface RateLimitEventHandler {
onRateLimit(retryAfter: number): void;
}
interface CacheEventHandler {
onCacheHit(key: string): void;
onCacheMiss(key: string): void;
}
interface AuthEventHandler {
onAuthRefresh(token: Token): void;
}
interface CircuitBreakerEventHandler {
onCircuitBreak(): void;
}
Now the consumer who only wants error logging implements exactly one interface:
// ✅ Clean, focused — no stubs, no noise
class ErrorLogger implements ErrorEventHandler {
onError(err: Error): void { console.error(err); }
onRetry(attempt: number): void {
console.warn(`Retrying... attempt ${attempt}`);
}
}
And your SDK accepts whichever combination the consumer provides:
class SdkClient {
constructor(private handlers: {
request?: RequestEventHandler;
error?: ErrorEventHandler;
rateLimit?: RateLimitEventHandler;
cache?: CacheEventHandler;
auth?: AuthEventHandler;
circuit?: CircuitBreakerEventHandler;
}) {}
}
// Consumer wires up only what they need
const client = new SdkClient({
error: new ErrorLogger(),
cache: new CacheMonitor(),
});
Adding a new event type to one interface no longer forces every other consumer to update their code.
ISP and the “implements everything” anti-pattern
There’s a specific ISP violation that shows up constantly in SDKs — the adapter or plugin base class that forces you to override a long list of lifecycle hooks:
// ❌ Base class with too many responsibilities
abstract class StorageAdapter {
abstract read(key: string): Promise<string>;
abstract write(key: string, value: string): Promise<void>;
abstract delete(key: string): Promise<void>;
abstract exists(key: string): Promise<boolean>;
abstract listKeys(prefix?: string): Promise<string[]>;
abstract clear(): Promise<void>;
abstract size(): Promise<number>;
abstract ttl(key: string): Promise<number>; // not all stores support this
abstract lock(key: string): Promise<void>; // not all stores support this
abstract unlock(key: string): Promise<void>; // not all stores support this
}
A simple in-memory adapter now has to stub out ttl, lock, and unlock — concepts that don't even apply to it. Stubs lie. They make your SDK harder to reason about.
The fix is to split by capability:
// ✅ Layered interfaces — implement only what your adapter actually supports
interface BasicStorage {
read(key: string): Promise<string>;
write(key: string, value: string): Promise<void>;
delete(key: string): Promise<void>;
}
interface QueryableStorage extends BasicStorage {
exists(key: string): Promise<boolean>;
listKeys(prefix?: string): Promise<string[]>;
}
interface TtlStorage extends BasicStorage {
ttl(key: string): Promise<number>;
}
interface LockableStorage extends BasicStorage {
lock(key: string): Promise<void>;
unlock(key: string): Promise<void>;
}
An in-memory adapter implements BasicStorage. A Redis adapter implements BasicStorage, TtlStorage, and LockableStorage. A read-only S3 adapter implements QueryableStorage. Each implements what it genuinely supports — nothing more.
The connection to DIP
ISP and DIP are natural partners. DIP says depend on abstractions. ISP says keep those abstractions lean.
When your abstractions are fat, DIP breaks down — because consumers end up depending on a large interface even when they only need a tiny slice of it. That tight coupling defeats the purpose of having the abstraction in the first place.
Small, focused interfaces are what make dependency injection truly composable:
// Each function declares only the capability it actually needs
function fetchWithRetry(
client: RequestEventHandler, // only needs request events
error: ErrorEventHandler // only needs error events
): Promise<Response> { ... }
// vs. the fat-interface version that drags in everything
function fetchWithRetry(handler: SdkEventHandler): Promise<Response> { ... }
💡 SDK insight: Every method on a public interface is a promise you make to every consumer who implements it. Adding a method to an interface is a breaking change for everyone. ISP keeps your surface area intentional — and your breaking changes rare.
How to audit your interfaces for ISP violations
Run this check on every interface you publish:
- Count the implementors. If only 2 out of 10 implementors ever use a method, it doesn’t belong on the shared interface.
- Count the stubs. If your tests or mocks are full of empty method bodies, your interfaces are too fat.
- Check for “not applicable” methods. If a method throws
NotSupportedExceptionor returns a placeholder in some implementations — that capability should be its own interface. - Look for natural groupings. Methods that always appear together belong together. Methods that appear independently belong apart.
The payoff
When ISP is done right, your SDK consumers experience something rare: an API that only asks them for what it actually needs. No stubs, no dead code, no interface contracts that expire the moment you ship a new event type.
Your SDK becomes a set of Lego bricks — composable, independent, predictable. Consumers assemble exactly the behavior they need without carrying the weight of everything they don’t.
That’s what great SDK design feels like from the outside. ISP is a big part of what makes it possible from the inside.
Next up — Part 4: Putting all five SOLID principles together in a real SDK architecture. The capstone of the series.
메타데이터
- post_id
- 0d28d4e1ca46
- slug
- stop-forcing-your-sdk-consumers-to-implement-methods-theyll-never-use-0d28d4e1ca46
- url
- https://medium.com/@jainsarwang/stop-forcing-your-sdk-consumers-to-implement-methods-theyll-never-use-0d28d4e1ca46
- canonical_url
- https://medium.com/@jainsarwang/stop-forcing-your-sdk-consumers-to-implement-methods-theyll-never-use-0d28d4e1ca46
- author_url
- https://medium.com/@jainsarwang
- status
- ok
- fetched_at
- 2026-06-27 18:20:27