Why Your Subclasses Are Secretly Breaking Your SDK — And How LSP Fixes It
Part 2 of the SOLID SDK Series. If you missed Part 1 covering SRP, OCP, and DIP — start there first.
Why Your Subclasses Are Secretly Breaking Your SDK — And How LSP Fixes It
Part 2 of the SOLID SDK Series. If you missed Part 1 covering SRP, OCP, and DIP — start there first.
There’s a bug that doesn’t look like a bug.
Your code compiles. Your types check out. Your base class works perfectly. But somewhere downstream, a consumer of your SDK passes in a subclass — and everything quietly falls apart. Wrong values returned. Silent failures. Behaviors that contradict what the interface promised.
This is the Liskov Substitution Principle violation. And it’s one of the sneakiest problems in object-oriented design.
“If it looks like a duck, quacks like a duck, but needs batteries — you probably have an abstraction violation.”
What is LSP?
The Liskov Substitution Principle was introduced by Barbara Liskov in 1987. In plain terms:
If S is a subtype of T, then objects of type T may be replaced with objects of type S without breaking the program.
In even plainer terms: a subclass should be fully substitutable for its parent class. If you swap a subclass in anywhere a parent class is expected, the program should behave correctly — no surprises, no exceptions, no special-casing.
It sounds obvious. It isn’t.
The classic violation — and why it matters for SDKs
The textbook example is the Rectangle / Square problem, but let me give you one that's painfully real when building an SDK.
Imagine you have a base StorageProvider class:
class StorageProvider {
read(key: string): string {
// reads from storage and returns value
}
write(key: string, value: string): void {
// writes value to storage
}
delete(key: string): void {
// deletes key from storage
}
}
Now imagine a ReadOnlyStorageProvider subclass:
// ❌ LSP violation — subclass breaks parent's contract
class ReadOnlyStorageProvider extends StorageProvider {
write(key: string, value: string): void {
throw new Error("This storage is read-only!");
}
delete(key: string): void {
throw new Error("This storage is read-only!");
}
}
This compiles. The types are fine. But if any part of your SDK receives a StorageProvider and calls .write() or .delete() on it — it will explode at runtime if handed a ReadOnlyStorageProvider.
Your consumers now have to write code like this:
// Consumer is forced to type-check — a smell that LSP was violated
if (provider instanceof ReadOnlyStorageProvider) {
// don't call write
} else {
provider.write(key, value);
}
That instanceof check is the smell. It tells you that the abstraction is broken.
The LSP-compliant fix
The solution is to redesign the hierarchy so subclasses only extend — never contradict — the parent’s contract.
// ✅ Separate what actually differs
interface ReadableStorage {
read(key: string): string;
}
interface WritableStorage extends ReadableStorage {
write(key: string, value: string): void;
delete(key: string): void;
}
class InMemoryStorage implements WritableStorage {
read(key: string): string { ... }
write(key: string, value: string): void { ... }
delete(key: string): void { ... }
}
class ReadOnlyStorage implements ReadableStorage {
read(key: string): string { ... }
// write and delete don't exist — not suppressed, just absent
}
Now your SDK functions declare exactly what they need:
// This function promises it only reads — consumers can safely pass ReadOnlyStorage
function fetchConfig(storage: ReadableStorage): Config { ... }
// This function needs full access — type system enforces it
function syncData(storage: WritableStorage): void { ... }
No surprises. No runtime explosions. No instanceof checks. The type system itself enforces the contract.
The three rules of LSP
When designing class hierarchies in your SDK, run through these checks:
1. Preconditions cannot be strengthened. A subclass cannot require more from callers than the parent does. If the parent accepts any string, the subclass can’t suddenly require a non-empty string.
2. Postconditions cannot be weakened. A subclass cannot promise less than the parent. If the parent guarantees it returns a non-null value, the subclass can’t start returning nulls.
3. Exceptions cannot be new or broader. A subclass cannot throw exceptions the parent never threw. Throwing NotImplementedError or ReadOnlyException from a method the parent never threw on — that's an LSP violation.
class BaseApiClient {
fetch(url: string): Promise<Response> {
// Never throws NetworkError — handles it internally
}
}
// ❌ Strengthened exception contract
class CachedApiClient extends BaseApiClient {
fetch(url: string): Promise<Response> {
if (!this.cache.has(url)) {
throw new CacheMissError("Not in cache"); // parent never threw this
}
return this.cache.get(url);
}
}
Consumers using BaseApiClient don't expect CacheMissError. Swapping in CachedApiClient silently breaks them.
Why LSP is critical in SDK design
When you publish an SDK, your consumers build on your abstractions. They write code against StorageProvider, AuthStrategy, HttpTransport — not against your specific implementations.
If they extend those abstractions for their own use case — a custom cache, a mock transport for testing, a no-op logger — they’re creating subclasses of your types. LSP determines whether those subclasses plug in safely or explode at runtime.
An SDK with clean LSP compliance is one where:
- Consumers can extend your base classes without fear
- Mocking any interface for tests just works
- New implementations drop in without changing calling code
An SDK that violates LSP is one where consumers dread inheritance and reach for workarounds.
💡 SDK insight: Before publishing any abstract base class or interface, ask yourself: “Could someone implement this contract in 10 different ways, and would all 10 work correctly anywhere my SDK expects this type?” If the answer is no — you have an LSP problem waiting to happen.
The real test
Here’s the mental test I now run on every abstraction before shipping it:
If I wrote a mock implementation of this interface that does the absolute minimum — returns default values, logs calls, does nothing — would the rest of the SDK still function correctly?
If the answer is yes, your abstraction is LSP-compliant. If parts of the SDK would break because they secretly depend on specific behaviors of the real implementation — you’ve got a violation hiding behind an interface.
LSP isn’t just about inheritance. It’s about making your promises explicit and keeping them consistently. That’s what separates an SDK that developers trust from one they tiptoe around.
Next up — Part 3: Interface Segregation Principle. Why fat interfaces are quietly punishing your SDK consumers, and how to design ones they’ll actually love.
메타데이터
- post_id
- 8cc260fba432
- slug
- why-your-subclasses-are-secretly-breaking-your-sdk-and-how-lsp-fixes-it-8cc260fba432
- url
- https://medium.com/@jainsarwang/why-your-subclasses-are-secretly-breaking-your-sdk-and-how-lsp-fixes-it-8cc260fba432
- canonical_url
- https://medium.com/@jainsarwang/why-your-subclasses-are-secretly-breaking-your-sdk-and-how-lsp-fixes-it-8cc260fba432
- author_url
- https://medium.com/@jainsarwang
- status
- ok
- fetched_at
- 2026-06-27 18:20:27